Skip to content

Conversation

@kixelated
Copy link
Collaborator

@kixelated kixelated commented Sep 3, 2025

And fix HTTPS so it's optional.

Summary by CodeRabbit

  • New Features

    • Added automatic WebSocket fallback/polyfill for WebTransport, enabling broader browser support (incl. Safari via LibAV WebCodecs).
    • Enhanced capability detection with partial support states for WebTransport and Opus.
    • Relay now supports configurable HTTP/HTTPS endpoints and optional WebSocket bridging.
  • Bug Fixes

    • Prevented zero-length chunk reads and improved unsigned integer parsing in stream/frame handling.
    • More reliable session closure logging.
  • Refactor

    • Generalized transport layer to a trait-based design and standardized transport error handling.
  • Chores

    • Dependency upgrades and streamlined build/check scripts.
    • Added and updated relay configuration files for dev/prod setups.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 3, 2025

Walkthrough

Introduces WebTransport polyfills and WebSocket fallback across JS and Rust, generalizes transport via traits in Rust, adds HTTP/HTTPS WebSocket support with JWT auth in relay, updates auth/cluster routing, and overhauls config. Also adjusts encoding/decoding utilities, support introspection, build scripts, and dependencies.

Changes

Cohort / File(s) Summary
JS Hang: LibAV polyfill and audio paths
js/hang/package.json, js/hang/src/util/libav.ts, js/hang/src/publish/audio/index.ts, js/hang/src/watch/audio/index.ts, js/hang/src/watch/audio/render-worklet.ts, js/hang/src/frame.ts
Adds LibAV WebCodecs polyfill loader and integrates it into async encoder/decoder setup; minor log removal; switches 2-byte reads to unsigned. Package adds @libav.js/variant-opus and libavjs-webcodecs-polyfill.
JS Hang: Connection and support status
js/hang/src/connection.ts, js/hang/src/support/index.ts, js/hang/src/support/element.ts
Adds websocket flag to connection; removes "unsupported" status; converts support reporting from booleans to Partial states ("full"/"partial"/"none") and updates UI bindings.
JS Moq: Connection polyfill race and stream fixes
js/moq/src/connection.ts, js/moq/src/lite/connection.ts, js/moq/src/stream.ts, js/moq/package.json
Implements WebTransport polyfill vs WebSocket race with cancellation and URL heuristics; logs outcomes; narrows test glob; adds WS polyfill dep; enforces non-empty chunks and unsigned short reads.
Rust Workspace deps refactor
rs/Cargo.toml, rs/.cargo/config.toml, rs/moq/Cargo.toml, rs/moq-native/Cargo.toml
Replaces web-transport with web-transport-trait, -quinn, -ws; removes compile cfg flag; bumps Tokio and rcgen; adds anyhow to native crate.
Rust: Generic transport in moq
rs/moq/src/session/mod.rs, rs/moq/src/session/stream.rs, rs/moq/src/session/writer.rs, rs/moq/src/session/reader.rs, rs/moq/src/session/publisher.rs, rs/moq/src/session/subscriber.rs, rs/moq/src/error.rs, rs/moq/src/lib.rs
Generalizes Session/Stream/Reader/Writer/Publisher/Subscriber over web_transport_trait::Session and associated streams; replaces WebTransport-specific errors with generic Transport errors; removes web_transport re-export.
Rust Native/CLI: Quinn path updates
rs/moq-native/src/client.rs, rs/moq-native/src/server.rs, rs/moq-native/src/lib.rs, rs/hang-cli/src/server.rs
Switches imports and ALPN from web_transport to web_transport_quinn; updates session/connect calls; adjusts TLS cert loading API and types.
Relay: Auth, routing, WS/HTTP server
rs/moq-relay/src/auth.rs, rs/moq-relay/src/cluster.rs, rs/moq-relay/src/config.rs, rs/moq-relay/src/connection.rs, rs/moq-relay/src/web.rs, rs/moq-relay/Cargo.toml, rs/moq-relay/src/main.rs
Rewrites auth API (path/token, AuthError); adds per-token publisher/subscriber routing; expands config with Web (HTTP/HTTPS, ws toggle); implements Axum-based WS upgrade bridging to WebTransport; updates main initialization.
Relay configs
rs/moq-relay/cfg/dev.toml, rs/moq-relay/cfg/leaf.toml, rs/moq-relay/cfg/root.toml, rs/moq-relay/cfg/prod.toml
Adds HTTP listeners, HTTPS production config, and public/anon auth key options; documents JWT and certificate endpoints.
Misc JS tooling
js/justfile, js/hang-demo/package.json, js/hang-demo/vite.config.ts, js/package.json, .github/workflows/check.yml, justfile, rs/justfile
Simplifies just targets (no flags), uses frozen/non-recursive installs, bumps dev deps and pnpm, excludes LibAV variant from Vite optimizeDeps, removes workspace flag in CI.
Minor JS/TS tweaks
js/hang/src/watch/element.ts
Comment typo fix.
Rust utilities
rs/moq/src/coding/varint.rs
Derive Debug for VarInt; use bit shifts in encode.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor App
  participant Hang as Hang.Connection
  participant MoqJS as js/moq connect()
  participant WT as WebTransport (native/polyfill)
  participant WS as WebSocket

  App->>Hang: new Connection(url, { websocket? })
  Hang->>MoqJS: connect(url, { websocket })
  rect rgba(200,230,255,0.3)
    note over MoqJS: Race with cancellation and URL heuristics
    par WT head-start (~200ms)
      MoqJS->>WT: connectWebTransport(url, options)
    and WS (if enabled)
      MoqJS->>WS: connectWebSocket(url)
    end
  end
  MoqJS-->>Hang: Connection (winner)
  Hang-->>App: status connected/failed
Loading
sequenceDiagram
  autonumber
  actor Client
  participant Web as Relay Web (Axum)
  participant Auth as Auth.verify(path, token)
  participant Cluster as Cluster
  participant Moq as moq_lite::Session

  Client->>Web: HTTP(S)/WS GET /... ?jwt=...
  Web->>Auth: verify(path, token)
  alt Authorized
    Auth-->>Web: AuthToken
    Web->>Cluster: publisher(token) / subscriber(token)
    Cluster-->>Web: handles (optional)
    Web->>Client: Upgrade to WS (if WS)
    Web->>Moq: Session::accept(sub, pub)
    Mooq->>Web: run until close
    Web-->>Client: Close on session end
  else Unauthorized
    Auth-->>Web: AuthError
    Web-->>Client: 401 / close request
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

A rabbit taps the transport lanes,
Webs and quinns through coded plains.
JWTs hop gates with gentle flare,
Polyfilled breezes in Safari air.
Streams now dance in generic light—
Squeak! All sessions run just right. 🐇✨

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch interface

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
js/moq/src/stream.ts (1)

388-402: Varint62 encoding off-by-one at boundaries (inflates 63 and 16383).

Using < instead of <= encodes 63 and 16383 in the next width (2 bytes and 4 bytes respectively), diverging from QUIC varint sizing.

 export function setVint62(dst: ArrayBuffer, v: bigint): Uint8Array {
-  if (v < MAX_U6) {
+  if (v <= MAX_U6) {
     return setUint8(dst, Number(v));
   }
-  if (v < MAX_U14) {
+  if (v <= MAX_U14) {
     return setUint16(dst, Number(v) | 0x4000);
   }
   if (v <= MAX_U30) {
     return setUint32(dst, Number(v) | 0x80000000);
   }
   //if (v <= MAX_U62) {
   return setUint64(dst, BigInt(v) | 0xc000000000000000n);

Please add tests that round‑trip encode/decode boundary values: 63, 64, 16383, 16384, 1073741823, 1073741824.

js/hang/src/connection.ts (1)

60-93: No reconnect on clean close; status never returns to "disconnected"

If the connection closes normally (no exception), we await connection.closed() and then exit without scheduling a retry or updating status. UI can get stuck and reload=true is ignored on clean closes.

Proposed fix:

       const connection = await Promise.race([cancel, pending]);
@@
       effect.set(this.status, "connected", "disconnected");
@@
-      await Promise.race([cancel, connection.closed()]);
+      await Promise.race([cancel, connection.closed()]);
+      // Transition back to disconnected on clean close and optionally reload.
+      effect.set(this.status, "disconnected", "connected");
+      effect.set(this.status, "disconnected", "connecting");
+      effect.set(this.established, undefined);
+      if (this.reload) {
+        const tick = this.#tick.peek() + 1;
+        // Use the base delay after a successful session.
+        effect.timer(() => this.#tick.set((prev) => Math.max(prev, tick)), this.delay);
+      }
     } catch (err) {
       console.warn("connection error:", err);
 
       // Exponential backoff.
       if (this.reload) {
+        // Ensure status reflects the failure.
+        effect.set(this.status, "disconnected", "connected");
+        effect.set(this.status, "disconnected", "connecting");
         const tick = this.#tick.peek() + 1;
 
         effect.timer(() => this.#tick.set((prev) => Math.max(prev, tick)), this.#delay);
 
         // Exponential backoff.
         this.#delay = Math.min(this.#delay * 2, this.maxDelay) as Time.Milli;
       }
js/hang/src/support/element.ts (1)

119-139: Bug: core support still treated as boolean; tri-state not propagated to summary.

support.webtransport is now "full" | "partial" | "none", but #getCoreSupport treats any truthy string as full, so "none" incorrectly maps to full. Also, #getSummary doesn’t degrade to "partial" when core is "partial".

Apply this diff:

-  #getSummary(support: Full, mode: SupportMode): "full" | "partial" | "none" {
-    const core = this.#getCoreSupport(support);
-
-    if (core === "none" || mode === "core") return core;
+  #getSummary(support: Full, mode: SupportMode): "full" | "partial" | "none" {
+    const core = this.#getCoreSupport(support);
+    if (mode === "core") return core;
+    if (core === "none") return "none";
@@
-    if (watch === "none" || publish === "none") return "none";
-    if (watch === "partial" && publish === "partial") return "partial";
-
-    return "full";
+    if (watch === "none" || publish === "none") return "none";
+    if (watch === "partial" && publish === "partial") return "partial";
+    if (core === "partial") return "partial";
+    return "full";
   }
 
-  #getCoreSupport(support: Full): "full" | "none" {
-    if (!support.webtransport) return "none";
-    return "full";
+  #getCoreSupport(support: Full): "full" | "partial" | "none" {
+    const wt = support.webtransport;
+    if (wt === "full") return "full";
+    if (wt === "partial") return "partial";
+    return "none";
   }

Also applies to: 141-144

js/hang/src/support/index.ts (1)

4-4: Rename Partial alias to SupportLevel
Avoid shadowing TS’s global Partial<T>. In js/hang/src/support/index.ts, update:

  • export type Partial = "full" | "partial" | "none";export type SupportLevel = "full" | "partial" | "none";
  • Change all local references (e.g. Full.webtransport, video.capture) to use SupportLevel.
    No external imports of the Partial alias detected.
rs/moq/src/session/writer.rs (1)

66-71: Don’t reset on Drop after a successful finish/abort.

Unconditional reset can convert a graceful finish into an abort on drop. Track completion and only reset if not finished/aborted.

Apply:

 pub(super) struct Writer<S: web_transport_trait::SendStream> {
-  stream: S,
+  stream: S,
   buffer: bytes::BytesMut,
+  done: bool,
 }
 
 impl<S: web_transport_trait::SendStream> Writer<S> {
   pub fn new(stream: S) -> Self {
     Self {
       stream,
       buffer: Default::default(),
+      done: false,
     }
   }
@@
   pub async fn finish(&mut self) -> Result<(), Error> {
-    self.stream.finish().await.map_err(|e| Error::Transport(e.into()))?;
+    self.stream.finish().await.map_err(|e| Error::Transport(e.into()))?;
+    self.done = true;
     Ok(())
   }
@@
   pub fn abort(&mut self, err: &Error) {
     self.stream.reset(err.to_code());
+    self.done = true;
   }
@@
 impl<S: web_transport_trait::SendStream> Drop for Writer<S> {
   fn drop(&mut self) {
-    // Unlike the Quinn default, we abort the stream on drop.
-    self.stream.reset(Error::Cancel.to_code());
+    // Abort only if not cleanly finished/aborted.
+    if !self.done {
+      self.stream.reset(Error::Cancel.to_code());
+    }
   }
 }
🧹 Nitpick comments (38)
rs/moq/src/coding/varint.rs (1)

206-214: Minor: prefer bit-shifts over integer pow for clarity and zero-cost constants

Shifts read more directly as bit-widths and avoid calling pow at runtime (however cheap). Purely a readability/micro-optimization nit.

Apply this diff:

-    if self.0 < 2u64.pow(6) {
+    if self.0 < (1u64 << 6) {
       w.put_u8(self.0 as u8);
-    } else if self.0 < 2u64.pow(14) {
+    } else if self.0 < (1u64 << 14) {
       w.put_u16((0b01 << 14) | self.0 as u16);
-    } else if self.0 < 2u64.pow(30) {
+    } else if self.0 < (1u64 << 30) {
       w.put_u32((0b10 << 30) | self.0 as u32);
     } else {
       w.put_u64((0b11 << 62) | self.0);
     }
js/moq/package.json (1)

23-25: Consider making the WS polyfill optional to keep installs lean.

If the polyfill is only dynamically imported on fallback, publishing it as an optional dependency avoids pulling it into environments that never use it.

   "dependencies": {
-    "async-mutex": "^0.5.0",
-    "@kixelated/web-transport-ws": "^0.1"
+    "async-mutex": "^0.5.0"
   },
+  "optionalDependencies": {
+    "@kixelated/web-transport-ws": "^0.1"
+  },

If you do need it always present (e.g., for bundlers without dynamic externals), keep it in dependencies.

rs/moq-relay/cfg/root.toml (2)

15-19: Port selection: TCP 4443 for HTTP may be confusing alongside QUIC on UDP 4443.

Sharing the same numeric port across TCP/UDP is valid, but 4443 usually implies TLS. Consider a distinct dev port (e.g., 4444) or add a clarifying comment to avoid operator confusion.

-[web.http]
-# Listen for HTTP and WebSocket (TCP) connections on the given address.
-# Defaults to disabled if not provided.
-listen = "[::]:4443"
+[web.http]
+# Listen for HTTP and WebSocket (TCP) connections on the given address.
+# Defaults to disabled if not provided.
+# Note: QUIC uses UDP:4443 above; choose a distinct TCP port for clarity in dev.
+listen = "[::]:4444"

15-19: Call out security/mixed‑content expectations for WS.

When serving from https origins, ws:// will be blocked as mixed content; operators will need wss://. A brief note here will prevent surprises.

 [web.http]
 # Listen for HTTP and WebSocket (TCP) connections on the given address.
 # Defaults to disabled if not provided.
 listen = "[::]:4443"
+#
+# Tip: Browsers block ws:// from https pages. Use a TLS terminator/reverse proxy
+# to expose wss:// in production even if QUIC/TLS is optional.
js/tsconfig.json (1)

9-11: Split debug maps into a dev tsconfig instead of commenting out.

Source maps and declaration maps are useful during local debugging but can stay off for publish. Consider a tsconfig.dev.json that extends this and enables both.

Example:

  • tsconfig.dev.json
    • { "extends": "./tsconfig.json", "compilerOptions": { "sourceMap": true, "declarationMap": true } }
  • Use it in dev scripts only.
js/moq/src/stream.ts (2)

69-81: Empty chunk handling may be too strict; also avoid an extra copy.

Throwing on zero-length chunks could break defensive polyfills; skipping them is safer. Also, wrapping result.value in a new Uint8Array copies data unnecessarily.

-    const result = await this.#reader.read();
-    if (result.done) {
-      return false;
-    }
-
-    if (result.value.byteLength === 0) {
-      throw new Error("unexpected empty chunk");
-    }
-
-    const buffer = new Uint8Array(result.value);
+    const result = await this.#reader.read();
+    if (result.done) return false;
+    const chunk = result.value;
+    // Some environments may yield empty chunks; ignore them defensively.
+    if (chunk.byteLength === 0) return true;
+    const buffer = chunk;

187-193: Use getUint16 for clarity (unsigned) when decoding 2‑byte varints.

Semantics are unsigned; masking fixes it, but using Uint avoids confusion.

-      const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength);
-      return BigInt(view.getInt16(0)) & 0x3fffn;
+      const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength);
+      return BigInt(view.getUint16(0)) & 0x3fffn;
js/justfile (1)

11-11: Use frozen installs in CI and make it the default via flags.

Reproducible installs reduce heisenbugs in CI. You already accept flags—default them to --frozen-lockfile.

-web url='http://localhost:4443/anon':
-	pnpm i
+web url='http://localhost:4443/anon':
+	pnpm install
@@
-check flags="":
-	pnpm install {{flags}}
+check flags="--frozen-lockfile":
+	pnpm install {{flags}}
@@
-fix flags="":
+fix flags="--frozen-lockfile":
 	# Fix the JS packages
 	pnpm install {{flags}}
@@
-build flags="":
-	pnpm install {{flags}}
+build flags="--frozen-lockfile":
+	pnpm install {{flags}}

Also applies to: 16-16, 33-33, 47-47

rs/moq-relay/cfg/leaf.toml (1)

15-18: Comment/port mismatch: QUIC comment says 4443 but listen/advertise use 4444

The new [web.http] TCP listener on 4444 looks fine alongside QUIC on UDP 4444, but the comment above still claims UDP:4443, which is misleading.

Apply one of:

-# Listen for QUIC connections on UDP:4443
+# Listen for QUIC connections on UDP:4444

or, if the intent was to keep QUIC on 4443:

-listen = "[::]:4444"
+listen = "[::]:4443"

Also applies to: 7-9

rs/moq-relay/src/auth.rs (2)

22-32: Return more precise HTTP status codes per auth failure

All AuthError variants currently map to 401. Suggest finer mapping to aid clients and logging:

  • UnexpectedToken -> 400 Bad Request
  • ExpectedToken, DecodeFailed -> 401 Unauthorized
  • IncorrectRoot -> 403 Forbidden
-impl From<AuthError> for http::StatusCode {
-    fn from(_: AuthError) -> Self {
-        http::StatusCode::UNAUTHORIZED
-    }
-}
+impl From<AuthError> for http::StatusCode {
+    fn from(err: AuthError) -> Self {
+        match err {
+            AuthError::UnexpectedToken => http::StatusCode::BAD_REQUEST,
+            AuthError::ExpectedToken | AuthError::DecodeFailed => http::StatusCode::UNAUTHORIZED,
+            AuthError::IncorrectRoot => http::StatusCode::FORBIDDEN,
+        }
+    }
+}
 
 impl axum::response::IntoResponse for AuthError {
-    fn into_response(self) -> axum::response::Response {
-        http::StatusCode::UNAUTHORIZED.into_response()
-    }
+    fn into_response(self) -> axum::response::Response {
+        let status: http::StatusCode = self.clone().into();
+        // Optional: include a short error string to aid debugging without leaking sensitive details.
+        (status, self.to_string()).into_response()
+    }
 }

95-110: Token/key handling edge: “UnexpectedToken” semantics

Returning UnexpectedToken when a token is provided but no key is configured is sensible; just confirm the web layer doesn’t prompt clients to retry with a token in this case. If you adopt the status code change above, this will become a 400 which matches the situation.

rs/moq-relay/Cargo.toml (1)

17-18: WS feature enablement and deps LGTM; consider minimal-feature axum.

The ws feature on axum and web-transport-ws dep fit the WS fallback. Optionally disable axum default features to keep the binary lean.

Suggested tweak (optional):

-axum = { version = "0.8", features = ["tokio", "ws"] }
+axum = { version = "0.8", default-features = false, features = ["tokio", "ws"] }

Also applies to: 20-20, 36-36

js/moq/src/lite/connection.ts (1)

115-124: Always-log finally is good; consider adding a local catch for decode errors for better context.

Right now any decode error surfaces only as a generic "fatal error running connection" from #run(). A localized error makes triage easier without changing behavior.

Apply:

 async #runSession() {
-    try {
+    try {
       // Receive messages until the connection is closed.
       for (;;) {
         const msg = await SessionInfo.decodeMaybe(this.#session.reader);
         if (!msg) break;
         // TODO use the session info
       }
+    } catch (err) {
+      if (!this.#closed) {
+        console.error("error reading session info", err);
+      }
     } finally {
       console.warn("session stream closed");
     }
   }
rs/moq-relay/cfg/dev.toml (1)

18-23: Bind HTTP to loopback by default in dev to avoid accidental exposure

Listening on all interfaces in dev can leak the fingerprint/track endpoints on shared machines. Prefer loopback.

Apply:

 [web.http]
 # Listen for HTTP and WebSocket connections on the given TCP address.
 # This is unfortunately required to serve certificate.sha256 for local development.
 # However, as a bonus, we can serve tracks via both HTTP and WebSocket fallbacks.
-listen = "[::]:4443"
+listen = "127.0.0.1:4443"
rs/moq-relay/cfg/prod.toml (3)

15-18: Document operational caveats for optional HTTP on :80

If enabled, binding :80 on Linux requires root or CAP_NET_BIND_SERVICE; behind a TLS terminator you likely don’t want a cleartext listener. Add a note to prevent misconfig.


19-27: Avoid TLS config duplication; ensure fail-fast if certs are missing

You repeat cert/key under both [server.tls] and [web.https]. Easy to drift. Consider a single tls.* block referenced by both, or at least add a comment stating they must be identical. Also ensure startup fails fast if either file is unreadable.


4-8: Same port (443) on UDP and TCP is OK; add a hint about firewall rules

Using UDP:443 for QUIC and TCP:443 for HTTPS is correct; add a brief note reminding operators to open both protocols in security groups/firewalls.

rs/moq-relay/src/main.rs (1)

28-36: Fail fast on web bind errors instead of panicking inside a task

If web.run() errors (e.g., EADDRINUSE), the panic happens in a spawned task and the process may keep serving QUIC, which is confusing. Prefer surfacing bind errors before entering the accept loop.

Example pattern (outside this hunk):

// Bind or validate listeners first so errors are synchronous.
let web = Web::new(state, config.web);
// If Web exposes a bind/prepare step, call it here and handle errors.
// web.prepare().context("failed to prepare web listeners")?;

tokio::spawn(async move {
    if let Err(err) = web.run().await {
        tracing::error!(%err, "web server exited");
        // Optionally: std::process::exit(1);
    }
});

Would you like me to propose a minimal prepare() API in rs/moq-relay/src/web.rs?

rs/moq-relay/src/config.rs (1)

33-37: Add inline config tests for CLI-over-file precedence and defaults

Given serde(flatten) + deny_unknown_fields, add small unit tests here to lock behavior.

You can append:

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    #[test]
    fn cli_overrides_file() {
        // Simulate: file sets web.http.listen=:80, CLI sets :8080
        let mut cfg: Config = toml::from_str(r#"
            [web.http]
            listen = "[::]:80"
        "#).unwrap();
        // Emulate CLI args
        cfg.update_from(["bin", "--web.http.listen", "[::]:8080"]);
        assert_eq!(cfg.web.http.as_ref().unwrap().listen.unwrap().to_string(), "[::]:8080");
    }

    #[test]
    fn default_web_is_disabled() {
        let cfg = Config::parse_from(["bin"]);
        assert!(cfg.web.http.is_none() && cfg.web.https.is_none());
    }
}
rs/moq-relay/src/cluster.rs (2)

71-74: Comment wording nit: “publish” vs “subscribe”.
This block is for subscribing; the comment says “publish our primary broadcasts.”

Apply:

  • “If this is a cluster node, then only serve our primary broadcasts.”
  • “Otherwise serve everything.”

69-95: Add inline tests for token scoping.
Cover: cluster=true vs false, valid/invalid roots, subscribe/publish filters.

I can add a minimal #[cfg(test)] block validating these branches.

rs/moq-relay/src/connection.rs (2)

15-23: Prefer Authorization header over URL query for JWTs

JWTs in query params are commonly logged by proxies and appear in referrers. Support Authorization: Bearer ... and fall back to query only if necessary.


38-49: Tidy duplicate “accepted” logs

You log “session accepted” twice (Line 33 and match arms). With the prior change, keep the detailed per-role logs here and drop the earlier one.

rs/moq-native/src/server.rs (2)

164-168: Surface accept errors instead of silently dropping

Currently, Errs from accept_session are ignored. Log them so operators can see handshake/ALPN issues.

-                Some(res) = self.accept.next() => {
-                    if let Ok(session) = res {
-                        return Some(session)
-                    }
-                }
+                Some(res) = self.accept.next() => {
+                    match res {
+                        Ok(session) => return Some(session),
+                        Err(e) => tracing::warn!(error = ?e, "failed to accept session"),
+                    }
+                }

228-229: Typo in comment

“cooresponding” → “corresponding”.

-    // Load a certificate and cooresponding key from a file
+    // Load a certificate and corresponding key from a file
rs/moq/src/session/reader.rs (2)

20-28: Remove commented-out accept() or gate with cfg(test)

Dead/commented code adds noise. Either delete or wrap a minimal test helper behind cfg(test).

-    /*
-    pub async fn accept<S: transport::Session>(session: &S) -> Result<Self, Error>
-    where
-        S::RecvStream: T,
-    {
-        let stream = session.accept_uni().await?;
-        Ok(Self::new(stream))
-    }
-    */

81-93: closed() semantics are correct; consider a unit test

Behavior (error on unexpected trailing bytes) is precise. Add a small inline test to lock this in.

rs/moq/src/error.rs (1)

88-89: MSRV safety: avoid io::Error::other (use new + Other).

If you keep the manual Clone fallback, Error::other requires newer Rust. Prefer:

- std::io::Error::other("Transport error (cloned)")
+ std::io::Error::new(std::io::ErrorKind::Other, "Transport error (cloned)")
rs/moq/src/session/writer.rs (1)

17-29: Remove stale commented code.

Dead/commented API adds noise. Drop or move behind a feature gate.

rs/moq/src/session/subscriber.rs (2)

56-59: Don’t swallow uni stream errors; log them.

Currently errors from run_uni_stream() are dropped. Emit a debug log to aid triage.

-			web_async::spawn(async move {
-				this.run_uni_stream(stream).await.ok();
-			});
+			web_async::spawn(async move {
+				if let Err(err) = this.run_uni_stream(stream).await {
+					tracing::debug!(%err, "uni stream error");
+				}
+			});

127-143: Broadcasts map is never populated; remove or insert on announce.

self.broadcasts is only removed from (Line 171) but never inserted, so the map is effectively unused. Either remove the field or insert entries when starting a broadcast.

 		// Run the broadcast in the background until all consumers are dropped.
 		self.origin
 			.as_mut()
 			.unwrap()
 			.publish_broadcast(path.clone(), broadcast.consumer);
 
-		web_async::spawn(self.clone().run_broadcast(path, broadcast.producer));
+		// Track for lookup/cleanup.
+		self.broadcasts.lock().insert(path.clone(), broadcast.producer.clone());
+
+		web_async::spawn(self.clone().run_broadcast(path, broadcast.producer));
rs/moq-relay/src/web.rs (1)

162-169: Typo: “Unfortuantely” → “Unfortunately”.

-		// Unfortuantely, we need to convert from Axum to Tungstenite.
+		// Unfortunately, we need to convert from Axum to Tungstenite.
rs/moq/src/session/mod.rs (6)

20-22: Make transport private to preserve invariants and allow future changes.

Exposing the transport directly couples external callers to the concrete transport API and makes it harder to evolve. Prefer a private field with an accessor.

Apply this diff:

-pub struct Session<S: web_transport_trait::Session> {
-    pub transport: S,
-}
+pub struct Session<S: web_transport_trait::Session> {
+    transport: S,
+}

And add a lightweight accessor inside this impl block:

pub fn transport(&self) -> &S {
    &self.transport
}

42-63: Handle Error::Cancel as a normal shutdown to avoid warning noise and confusing close codes.

run_session() returns Error::Cancel on graceful end-of-stream. The current match treats it as an error (warn, non-zero close code via err.to_code()), which is misleading.

Apply this diff to add a dedicated branch before the generic Err(err) arm:

 			match res {
-				Err(Error::Transport(_)) => {
+				Err(Error::Transport(_)) => {
 					tracing::info!("session terminated");
 					session.close(1, "");
-				}
+				}
+				Err(Error::Cancel) => {
+					tracing::info!("session canceled");
+					session.close(0, "");
+				}
 				Err(err) => {
 					tracing::warn!(%err, "session error");
 					session.close(err.to_code(), err.to_string().as_ref());
 				}

Also, confirm close() is idempotent across concurrent callers (user calling Session::close() vs. the background task). If idempotency is not guaranteed by the transport, consider guarding with an atomic once flag.


69-69: Return a more specific error than Cancel on init failure.

When the announce init oneshot fails, mapping to Error::Cancel hides the cause (e.g., early stream close vs. setup error). Prefer a dedicated handshake/init error.

Example:

  • Introduce Error::Handshake(&'static str) or Error::InitFailed.
  • Use it here to aid diagnostics and metrics.

I can wire this through error.rs if you want.


143-146: Consider treating end-of-session as success to simplify callers.

Right now a clean end-of-stream yields Error::Cancel. If you adopt the dedicated match arm above, you can keep this; alternatively, returning Ok(()) avoids propagating a pseudo-error.

Alternative diff if you prefer the latter:

-while let Some(_info) = stream.reader.decode_maybe::<message::SessionInfo>().await? {}
-Err(Error::Cancel)
+while let Some(_info) = stream.reader.decode_maybe::<message::SessionInfo>().await? {}
+Ok(())

149-156: Minor: document close/closed semantics and reason lifetimes.

close(self, err) consumes self and passes err.to_string().as_ref(); this borrows a temporary, which is fine if close doesn't retain it. Add a brief doc note to clarify that the reason is used synchronously and not retained.


1-16: Add inline unit tests under #[cfg(test)] per repo guidelines.

Given the new generic transport, add tests using a mock S to validate:

  • Successful connect/accept negotiation.
  • Version mismatch path.
  • Error::Cancel handling and close code mapping.

I can scaffold a #[cfg(test)] module with a minimal mock implementing web_transport_trait::Session to exercise connect_setup/accept_setup.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between ffb4434 and d5b810b.

⛔ Files ignored due to path filters (2)
  • js/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • rs/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (38)
  • js/hang/package.json (0 hunks)
  • js/hang/src/connection.ts (1 hunks)
  • js/hang/src/support/element.ts (1 hunks)
  • js/hang/src/support/index.ts (2 hunks)
  • js/justfile (3 hunks)
  • js/moq/package.json (1 hunks)
  • js/moq/src/connection.ts (3 hunks)
  • js/moq/src/lite/connection.ts (1 hunks)
  • js/moq/src/stream.ts (1 hunks)
  • js/tsconfig.json (1 hunks)
  • rs/.cargo/config.toml (0 hunks)
  • rs/Cargo.toml (1 hunks)
  • rs/hang-cli/src/server.rs (2 hunks)
  • rs/moq-native/Cargo.toml (2 hunks)
  • rs/moq-native/src/client.rs (2 hunks)
  • rs/moq-native/src/lib.rs (1 hunks)
  • rs/moq-native/src/server.rs (4 hunks)
  • rs/moq-relay/Cargo.toml (2 hunks)
  • rs/moq-relay/cfg/dev.toml (1 hunks)
  • rs/moq-relay/cfg/leaf.toml (1 hunks)
  • rs/moq-relay/cfg/prod.toml (1 hunks)
  • rs/moq-relay/cfg/root.toml (1 hunks)
  • rs/moq-relay/src/auth.rs (21 hunks)
  • rs/moq-relay/src/cluster.rs (2 hunks)
  • rs/moq-relay/src/config.rs (2 hunks)
  • rs/moq-relay/src/connection.rs (1 hunks)
  • rs/moq-relay/src/main.rs (1 hunks)
  • rs/moq-relay/src/web.rs (4 hunks)
  • rs/moq/Cargo.toml (1 hunks)
  • rs/moq/src/coding/varint.rs (1 hunks)
  • rs/moq/src/error.rs (3 hunks)
  • rs/moq/src/lib.rs (1 hunks)
  • rs/moq/src/session/mod.rs (6 hunks)
  • rs/moq/src/session/publisher.rs (14 hunks)
  • rs/moq/src/session/reader.rs (3 hunks)
  • rs/moq/src/session/stream.rs (2 hunks)
  • rs/moq/src/session/subscriber.rs (13 hunks)
  • rs/moq/src/session/writer.rs (2 hunks)
💤 Files with no reviewable changes (2)
  • js/hang/package.json
  • rs/.cargo/config.toml
🧰 Additional context used
📓 Path-based instructions (6)
rs/moq-relay/**

📄 CodeRabbit inference engine (CLAUDE.md)

The CDN/relay (rs/moq-relay) must not include application logic, media codecs, or track details

Files:

  • rs/moq-relay/cfg/prod.toml
  • rs/moq-relay/cfg/root.toml
  • rs/moq-relay/src/config.rs
  • rs/moq-relay/cfg/leaf.toml
  • rs/moq-relay/cfg/dev.toml
  • rs/moq-relay/Cargo.toml
  • rs/moq-relay/src/cluster.rs
  • rs/moq-relay/src/auth.rs
  • rs/moq-relay/src/connection.rs
  • rs/moq-relay/src/main.rs
  • rs/moq-relay/src/web.rs
rs/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place Rust crates under the rs/ directory

Files:

  • rs/moq-relay/cfg/prod.toml
  • rs/moq/Cargo.toml
  • rs/moq-relay/cfg/root.toml
  • rs/moq-native/src/lib.rs
  • rs/moq/src/lib.rs
  • rs/moq-relay/src/config.rs
  • rs/moq-relay/cfg/leaf.toml
  • rs/Cargo.toml
  • rs/moq/src/coding/varint.rs
  • rs/moq-relay/cfg/dev.toml
  • rs/moq-native/src/client.rs
  • rs/moq-relay/Cargo.toml
  • rs/moq-relay/src/cluster.rs
  • rs/moq-native/Cargo.toml
  • rs/hang-cli/src/server.rs
  • rs/moq/src/error.rs
  • rs/moq/src/session/stream.rs
  • rs/moq-relay/src/auth.rs
  • rs/moq-relay/src/connection.rs
  • rs/moq-relay/src/main.rs
  • rs/moq-relay/src/web.rs
  • rs/moq-native/src/server.rs
  • rs/moq/src/session/reader.rs
  • rs/moq/src/session/subscriber.rs
  • rs/moq/src/session/writer.rs
  • rs/moq/src/session/mod.rs
  • rs/moq/src/session/publisher.rs
js/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place JS/TS packages under the js/ directory

Files:

  • js/moq/package.json
  • js/moq/src/lite/connection.ts
  • js/hang/src/support/element.ts
  • js/moq/src/stream.ts
  • js/hang/src/connection.ts
  • js/moq/src/connection.ts
  • js/tsconfig.json
  • js/justfile
  • js/hang/src/support/index.ts
rs/**/src/**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

Rust tests should be integrated within source files (inline unit tests)

Files:

  • rs/moq-native/src/lib.rs
  • rs/moq/src/lib.rs
  • rs/moq-relay/src/config.rs
  • rs/moq/src/coding/varint.rs
  • rs/moq-native/src/client.rs
  • rs/moq-relay/src/cluster.rs
  • rs/hang-cli/src/server.rs
  • rs/moq/src/error.rs
  • rs/moq/src/session/stream.rs
  • rs/moq-relay/src/auth.rs
  • rs/moq-relay/src/connection.rs
  • rs/moq-relay/src/main.rs
  • rs/moq-relay/src/web.rs
  • rs/moq-native/src/server.rs
  • rs/moq/src/session/reader.rs
  • rs/moq/src/session/subscriber.rs
  • rs/moq/src/session/writer.rs
  • rs/moq/src/session/mod.rs
  • rs/moq/src/session/publisher.rs
{rs,js}/hang/**

📄 CodeRabbit inference engine (CLAUDE.md)

All media logic belongs in the hang layer (rs/hang and js/hang)

Files:

  • js/hang/src/support/element.ts
  • js/hang/src/connection.ts
  • js/hang/src/support/index.ts
rs/Cargo.toml

📄 CodeRabbit inference engine (CLAUDE.md)

Maintain the Rust workspace configuration in rs/Cargo.toml

Files:

  • rs/Cargo.toml
🧠 Learnings (1)
📚 Learning: 2025-08-13T21:30:35.001Z
Learnt from: CR
PR: kixelated/moq#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-13T21:30:35.001Z
Learning: Applies to rs/Cargo.toml : Maintain the Rust workspace configuration in rs/Cargo.toml

Applied to files:

  • rs/Cargo.toml
🧬 Code graph analysis (13)
js/moq/src/lite/connection.ts (2)
js/moq/src/ietf/connection.ts (2)
  • msg (125-190)
  • msg (196-201)
js/moq/src/lite/session.ts (1)
  • SessionInfo (136-164)
js/moq/src/connection.ts (1)
js/hang/src/publish/element.ts (2)
  • url (92-94)
  • url (96-98)
rs/moq/src/error.rs (1)
rs/moq/src/session/mod.rs (1)
  • new (25-72)
rs/moq/src/session/stream.rs (1)
rs/moq/src/session/mod.rs (1)
  • accept (101-112)
rs/moq-relay/src/auth.rs (3)
rs/moq/src/path.rs (10)
  • from (233-235)
  • from (239-242)
  • from (252-272)
  • new (82-98)
  • as_path (13-13)
  • as_path (17-19)
  • as_path (23-26)
  • as_path (30-32)
  • as_path (36-38)
  • as_path (42-44)
rs/moq-relay/src/web.rs (4)
  • into_response (294-296)
  • into_response (329-331)
  • new (89-91)
  • new (273-275)
rs/moq/src/model/origin.rs (7)
  • root (285-310)
  • root (425-427)
  • root (515-517)
  • new (17-19)
  • new (62-67)
  • new (112-118)
  • new (451-470)
rs/moq-relay/src/connection.rs (1)
rs/moq/src/model/origin.rs (5)
  • root (285-310)
  • root (425-427)
  • root (515-517)
  • allowed (429-431)
  • allowed (519-521)
rs/moq-relay/src/main.rs (3)
rs/moq-relay/src/web.rs (2)
  • new (89-91)
  • new (273-275)
rs/moq-relay/src/cluster.rs (1)
  • new (58-67)
rs/moq-relay/src/auth.rs (1)
  • new (70-88)
rs/moq-relay/src/web.rs (5)
rs/moq-relay/src/cluster.rs (3)
  • get (96-101)
  • new (58-67)
  • run (103-150)
rs/moq-native/src/server.rs (3)
  • new (86-146)
  • accept (157-178)
  • close (217-219)
rs/moq/src/session/mod.rs (3)
  • new (25-72)
  • accept (101-112)
  • close (149-151)
rs/moq-relay/src/auth.rs (1)
  • new (70-88)
rs/moq-relay/src/connection.rs (1)
  • run (14-61)
rs/moq/src/session/reader.rs (2)
rs/moq/src/session/mod.rs (3)
  • new (25-72)
  • stream (144-144)
  • closed (154-156)
rs/moq/src/session/writer.rs (2)
  • new (10-15)
  • closed (60-63)
rs/moq/src/session/subscriber.rs (3)
rs/moq/src/session/reader.rs (1)
  • new (13-18)
rs/moq/src/session/writer.rs (1)
  • new (10-15)
rs/moq/src/session/stream.rs (1)
  • open (10-18)
rs/moq/src/session/writer.rs (3)
rs/moq/src/session/mod.rs (2)
  • new (25-72)
  • stream (144-144)
rs/moq/src/session/reader.rs (1)
  • new (13-18)
rs/moq/src/coding/encode.rs (11)
  • encode (6-6)
  • encode (10-12)
  • encode (16-18)
  • encode (22-25)
  • encode (29-32)
  • encode (36-41)
  • encode (45-50)
  • encode (54-57)
  • encode (61-64)
  • encode (68-70)
  • encode (74-77)
rs/moq/src/session/mod.rs (4)
rs/moq/src/session/publisher.rs (3)
  • new (16-20)
  • stream (48-48)
  • stream (136-136)
rs/moq/src/session/reader.rs (2)
  • new (13-18)
  • closed (80-93)
rs/moq/src/session/writer.rs (2)
  • new (10-15)
  • closed (60-63)
rs/moq/src/session/stream.rs (2)
  • open (10-18)
  • accept (20-27)
rs/moq/src/session/publisher.rs (3)
rs/moq/src/session/mod.rs (3)
  • new (25-72)
  • stream (144-144)
  • accept (101-112)
rs/moq/src/session/writer.rs (2)
  • new (10-15)
  • finish (51-54)
rs/moq/src/session/stream.rs (1)
  • accept (20-27)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Check
🔇 Additional comments (34)
rs/moq/src/coding/varint.rs (1)

21-21: Adding Debug to VarInt is a safe, helpful diagnostic enhancement — LGTM

This is an additive trait impl on a public type, non-breaking, and improves logging/printf-debugging without behavior changes.

js/hang/src/connection.ts (1)

22-22: Removal of “unsupported” from ConnectionStatus is safe
Search across TS/TSX shows no callers referencing “unsupported” for ConnectionStatus in js/hang; safe to merge.

rs/moq-relay/src/auth.rs (2)

116-121: Root-prefix check looks correct with normalized paths

Using Path::new(path) and strip_prefix(claims.root) enforces that the requested path lies under the token’s root. Normalization in moq::Path handles leading/trailing and duplicate slashes. LGTM.


123-147: Permission reduction logic is sound; preserves/strips as intended

Reducing subscribe/publish by the derived suffix correctly narrows permissions when connecting deeper than the token root. Tests cover empty, specific, and nested cases. LGTM.

rs/moq/Cargo.toml (1)

32-32: Switch to trait abstraction looks good.

Using web-transport-trait at the core crate level aligns with the transport split and enables multiple backends. No issues spotted here.

js/hang/src/support/element.ts (1)

288-288: Row change aligns UI with tri-state WebTransport.

Good switch to partial(support.webtransport) to reflect Full/Partial/None in the grid.

rs/moq-native/Cargo.toml (1)

36-36: Quinn backend wiring is correct.

web-transport-quinn matches the workspace split and downstream imports. No issues here.

rs/moq/src/lib.rs (1)

24-24: Verify removal of moq[_lite]::web_transport and update docs

  • Local grep shows no internal references; manually confirm no downstream crates on crates.io rely on moq[_lite]::web_transport.
  • Add migration notes to CHANGELOG/RELEASE_NOTES detailing the split into web-transport-{quinn,ws,trait}.
rs/moq-native/src/client.rs (1)

170-175: ALPN selection for HTTPS looks correct.

Mapping "https"web_transport_quinn::ALPN and "moql"moq_lite::ALPN is appropriate.

rs/moq-relay/src/main.rs (1)

28-36: Good separation of Web state vs. config

Moving to Web::new(WebState, WebConfig) improves testability and config clarity. No issues.

rs/Cargo.toml (1)

23-25: No stale web-transport dependencies remain. All references have been updated to the new web-transport-quinn, web-transport-trait, and web-transport-ws crates.

rs/moq-relay/src/config.rs (1)

4-4: Import of WebConfig is correct and localized

Keeps Web concerns within moq-relay; conforms to workspace structure.

rs/hang-cli/src/server.rs (2)

7-7: Import path update looks correct.
New path aligns with moq-native’s re-exports.


69-69: Session type swap is consistent with the import.
Signature matches the new Request type.

js/moq/src/connection.ts (1)

41-44: Confirm polyfill URL expectations
Please share the @kixelated/web-transport-ws npm/GitHub link or paste its README/constructor implementation so we can verify whether it accepts http(s) and auto-converts to ws(s), or if callers must supply ws(s) URLs.

rs/moq-relay/src/connection.rs (3)

51-53: Good: defer handshake until after auth

Accepting the WebTransport request only after verify() prevents unauthenticated sessions from establishing. Nice.


54-58: Relay stays free of app/codec specifics

This file manages auth/route/session wiring without embedding application logic, media codecs, or track details—aligned with rs/moq-relay guidelines.


59-61: Confirm the contract for “normal” session closure

run() always returns Err(session.closed().await.into()). If clean shutdowns are expected to be non-errors at the call site, consider mapping a normal close to Ok(()).

rs/moq/src/session/reader.rs (3)

7-14: Generic Reader over RecvStream looks good

Type generalization aligns with the transport trait and keeps the constructor clean.


40-46: Good: unify transport errors

Mapping read_buf errors into Error::Transport ensures consistent error handling across transports.


73-77: Verify external RecvStream::read_chunk signature
Confirm that the imported RecvStream::read_chunk(max: usize) indeed returns Result<Option<Bytes>, _> with no extra metadata or offset; if it returns a different type, adapt its output to Option<Bytes> before mapping into your Error::Transport.

rs/moq/src/session/stream.rs (3)

4-7: Good move: generic Stream over transport session.

Struct generics and associated stream types are clean and align with the trait-based refactor.


11-18: ControlType write on open looks correct.

Encoding the control type immediately on bi-open is consistent with accept-side decode in session/mod.rs.


10-12: Confirm error conversion assumptions for Session::open_bi()/accept_bi()
Ensure the error type returned by web_transport_trait::Session methods implements Into<Box<dyn std::error::Error + Send + Sync>>. If it doesn’t, replace:

-.map_err(|err| Error::Transport(err.into()))?
+.map_err(|err| Error::Transport(Box::new(err)))?
rs/moq/src/error.rs (1)

62-83: Wire code mapping looks consistent.

Transport mapped to code 4 aligns with the session close logic.

rs/moq/src/session/writer.rs (1)

31-43: encode path looks solid.

Buffer reuse + write loop is efficient and straightforward.

rs/moq/src/session/subscriber.rs (1)

286-302: Frame read/size accounting looks solid.

Correctly handles EOF/size mismatch and avoids underflow with checked_sub; trace logs are helpful.

rs/moq-relay/src/web.rs (1)

142-174: Confirm path format passed to auth.verify().

Previously we passed request.url().path() (leading “/”). Now the path extractor yields no leading slash. If verify expects a leading slash, token checks may fail. Consider normalizing (format!("/{}", path)) or updating verify().

Would you like me to scan the repo to confirm auth.verify’s expected path format and update the call sites accordingly?

rs/moq/src/session/publisher.rs (2)

276-327: Streaming group writer is correct and resilient.

Good use of open_uni + priority, cancellation via stream.closed(), chunked writes, and finish().


335-358: Priority mapping and tests look good.

The 24-bit packing and test coverage are clear and sufficient.

rs/moq/src/session/mod.rs (4)

74-84: LGTM: client connect path moves to generic transport and immutable session borrow.

The switch to Stream::open(&session, ...) and the generic S looks clean; setup ordering and error propagation are preserved.


100-112: LGTM: server accept path with generic S and inferred ControlType.

Accepting via Stream::accept(&session) and deferring kind validation to accept_setup keeps concerns separated.


114-140: Version gating and backward-compatibility look correct.

  • Properly rejects unsupported versions.
  • Sends ServerCompat preface when ClientCompat is used, then ServerSetup.

24-38: Add explicit Clone bound to new()

session.clone() is called multiple times in Session::new, so require S: Clone on the method signature. Optionally, if web_async::spawn is an alias for tokio::spawn, you’ll also need S: Send + 'static—please verify and adjust accordingly.

Apply:

 impl<S: web_transport_trait::Session + Sync> Session<S> {
-   async fn new(
+   async fn new(
        session: S,
        stream: Stream<S>,
        publish: Option<OriginConsumer>,
        subscribe: Option<OriginProducer>,
-)      -> Result<Self, Error> {
+)      -> Result<Self, Error>
+   where
+       S: Clone,
+   {

Comment on lines 56 to 59
// Fetch the fingerprint from the server.
const fingerprint = await fetch(fingerprintUrl);
const fingerprintText = await fingerprint.text();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Harden fingerprint fetch: check status and trim whitespace.
Avoids opaque failures on 404/HTML and handles trailing newlines.

Apply this diff:

-			// Fetch the fingerprint from the server.
-			const fingerprint = await fetch(fingerprintUrl);
-			const fingerprintText = await fingerprint.text();
+			// Fetch the fingerprint from the server.
+			const res = await fetch(fingerprintUrl);
+			if (!res.ok) {
+				throw new Error(`fingerprint fetch failed: ${res.status} ${res.statusText}`);
+			}
+			const fingerprintText = (await res.text()).trim();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Fetch the fingerprint from the server.
const fingerprint = await fetch(fingerprintUrl);
const fingerprintText = await fingerprint.text();
// Fetch the fingerprint from the server.
- const fingerprint = await fetch(fingerprintUrl);
const res = await fetch(fingerprintUrl);
if (!res.ok) {
throw new Error(`fingerprint fetch failed: ${res.status} ${res.statusText}`);
}
const fingerprintText = (await res.text()).trim();
🤖 Prompt for AI Agents
In js/moq/src/connection.ts around lines 56 to 59, the code fetches the
fingerprint but doesn't validate the HTTP status or trim whitespace; update the
fetch to check response.ok (or status === 200) and if not, read the response
text and throw an error including the HTTP status and response body to avoid
opaque failures on 404/HTML, then call .text(), trim the result (e.g.
fingerprintText.trim()) and use the trimmed fingerprint; ensure any thrown
errors include context (URL and status) for easier debugging.

Comment on lines 189 to 193
let session = match url.scheme() {
"https" => web_transport::quinn::Session::connect(connection, url).await?,
moq_lite::ALPN => web_transport::quinn::Session::raw(connection, url),
"https" => web_transport_quinn::Session::connect(connection, url).await?,
moq_lite::ALPN => web_transport_quinn::Session::raw(connection, url),
_ => unreachable!(),
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: Matching url.scheme() against moq_lite::ALPN will never hit; use "moql".

This makes the "moql" path unreachable and will unreachable!() at runtime.

Fix:

-    let session = match url.scheme() {
-        "https" => web_transport_quinn::Session::connect(connection, url).await?,
-        moq_lite::ALPN => web_transport_quinn::Session::raw(connection, url),
-        _ => unreachable!(),
-    };
+    let session = match url.scheme() {
+        "https" => web_transport_quinn::Session::connect(connection, url).await?,
+        "moql"  => web_transport_quinn::Session::raw(connection, url),
+        _ => unreachable!(),
+    };

Optional: derive both ALPN and session constructor from a single parsed enum of the scheme to avoid future drift between the two matches.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let session = match url.scheme() {
"https" => web_transport::quinn::Session::connect(connection, url).await?,
moq_lite::ALPN => web_transport::quinn::Session::raw(connection, url),
"https" => web_transport_quinn::Session::connect(connection, url).await?,
moq_lite::ALPN => web_transport_quinn::Session::raw(connection, url),
_ => unreachable!(),
};
let session = match url.scheme() {
"https" => web_transport_quinn::Session::connect(connection, url).await?,
"moql" => web_transport_quinn::Session::raw(connection, url),
_ => unreachable!(),
};
🤖 Prompt for AI Agents
In rs/moq-native/src/client.rs around lines 189 to 193, the match arm compares
url.scheme() to moq_lite::ALPN which is the ALPN identifier (not the URL scheme)
so the "moql" path is never matched and the code can hit unreachable!(); change
the match to compare against the actual scheme string "moql" (i.e. use "moql" =>
web_transport_quinn::Session::raw(...)) so the correct branch is taken;
optionally refactor to parse the URL scheme into a small enum and derive both
the ALPN and the session constructor from that enum to keep them in sync.

// Re-export these crates.
pub use moq_lite;
pub use web_transport;
pub use web_transport_quinn;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Avoid breaking change: keep old re-export alias.

Replacing pub use web_transport; with pub use web_transport_quinn; breaks external users importing moq_native::web_transport. Either add a compatibility alias or bump the crate version.

Apply this diff to preserve the old path:

 pub use moq_lite;
-pub use web_transport_quinn;
+pub use web_transport_quinn;
+// Back-compat alias for external crates that used `moq_native::web_transport`
+pub use web_transport_quinn as web_transport;

If you prefer not to alias, please bump moq-native to 0.8.0.


🏁 Script executed:

#!/bin/bash
# Search for any existing re-export of `web_transport` and inspect lib.rs
rg -n "pub use web_transport" -g "*.rs" || true
sed -n '1,20p' rs/moq-native/src/lib.rs || true

Length of output: 313


Preserve backward compatibility for moq_native::web_transport
In rs/moq-native/src/lib.rs after the pub use web_transport_quinn; re-export, add:

 pub use moq_lite;
 pub use web_transport_quinn;
+// Back-compat alias for external crates using `moq_native::web_transport`
+pub use web_transport_quinn as web_transport;

Alternatively, bump the moq-native crate version to 0.8.0.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub use web_transport_quinn;
pub use moq_lite;
pub use web_transport_quinn;
// Back-compat alias for external crates using `moq_native::web_transport`
pub use web_transport_quinn as web_transport;
🤖 Prompt for AI Agents
In rs/moq-native/src/lib.rs around line 11, maintain backward compatibility for
moq_native::web_transport by adding a re-export alias for the renamed
dependency; specifically add a public re-export that aliases web_transport_quinn
to the old module name (i.e., re-export web_transport_quinn as web_transport) so
existing callers continue to resolve moq_native::web_transport, or alternatively
increment the moq-native crate version to 0.8.0 if you intend to break
compatibility.

Comment on lines +150 to +156
let token = state.auth.verify(&path, params.jwt.as_deref())?;
let publish = state.cluster.publisher(&token);
let subscribe = state.cluster.subscriber(&token);

if publish.is_none() && subscribe.is_none() {
// Bad token, we can't publish or subscribe.
return Err(StatusCode::UNAUTHORIZED.into());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Map auth failures to 401 instead of propagating internal errors.

Explicitly convert verification errors to UNAUTHORIZED for WS, /announced, and /fetch.

-	let token = state.auth.verify(&path, params.jwt.as_deref())?;
+	let token = state
+		.auth
+		.verify(&path, params.jwt.as_deref())
+		.map_err(|_| StatusCode::UNAUTHORIZED)?;
@@
-	let token = state.auth.verify(&prefix, params.jwt.as_deref())?;
+	let token = state
+		.auth
+		.verify(&prefix, params.jwt.as_deref())
+		.map_err(|_| StatusCode::UNAUTHORIZED)?;
@@
-	let token = state.auth.verify(&broadcast, params.jwt.as_deref())?;
+	let token = state
+		.auth
+		.verify(&broadcast, params.jwt.as_deref())
+		.map_err(|_| StatusCode::UNAUTHORIZED)?;

Also applies to: 208-212, 242-246

🤖 Prompt for AI Agents
In rs/moq-relay/src/web.rs around lines 150-156 (and similarly at 208-212,
242-246), the auth verification call can return internal errors that are
currently propagated; instead, catch verification failures and map them to
StatusCode::UNAUTHORIZED so clients receive 401 rather than internal errors.
Change the code to handle the Result from state.auth.verify by matching or using
map_err to convert any verification error into an UNAUTHORIZED error (returning
Err(StatusCode::UNAUTHORIZED.into())), then proceed to check publish/subscribe
as before; apply the same pattern at the other indicated line ranges (WS,
/announced, /fetch).

Comment on lines 232 to 237
let mut path: Vec<&str> = path.split("/").collect();
if path.len() < 2 {
let track = path.pop().unwrap().to_string();

// We need at least a broadcast and a track.
if path.is_empty() {
return Err(StatusCode::BAD_REQUEST.into());
Copy link
Contributor

@coderabbitai coderabbitai bot Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Path parsing can panic on empty or single-segment paths.

path.pop().unwrap() will panic. Also handle trailing slashes.

-	// The path containts a broadcast/track
-	let mut path: Vec<&str> = path.split("/").collect();
-	let track = path.pop().unwrap().to_string();
+	// The path contains a broadcast/track; tolerate extra slashes.
+	let mut path: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
+	let Some(track) = path.pop().map(|s| s.to_string()) else {
+		return Err(StatusCode::BAD_REQUEST.into());
+	};
 
 	// We need at least a broadcast and a track.
 	if path.is_empty() {
 		return Err(StatusCode::BAD_REQUEST.into());
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mut path: Vec<&str> = path.split("/").collect();
if path.len() < 2 {
let track = path.pop().unwrap().to_string();
// We need at least a broadcast and a track.
if path.is_empty() {
return Err(StatusCode::BAD_REQUEST.into());
// The path contains a broadcast/track; tolerate extra slashes.
let mut path: Vec<&str> = path
.split('/')
.filter(|s| !s.is_empty())
.collect();
let Some(track) = path.pop().map(|s| s.to_string()) else {
return Err(StatusCode::BAD_REQUEST.into());
};
// We need at least a broadcast and a track.
if path.is_empty() {
return Err(StatusCode::BAD_REQUEST.into());
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will split always return at least one result, or can it return an empty vec?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kixelated str::split() always returns at least one element, even for empty strings:

  • "".split('/')[""] (one empty string)
  • "abc".split('/')["abc"] (one element)
  • "/".split('/')["", ""] (two empty strings)
  • "a/b".split('/')["a", "b"] (two elements)

So path.pop().unwrap() would never panic from an empty vec after split(). However, it could still be problematic with paths like "/" or "//" where you get only empty segments. That's why the .filter(|s| !s.is_empty()) in my suggestion helps handle trailing/leading slashes and multiple consecutive slashes gracefully.

Comment on lines 6 to 8
#[error("transport error: {0}")]
Transport(Box<dyn std::error::Error + Send + Sync>),

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Preserve transport error on clone using Arc instead of placeholder io::Error.

Cloning to a synthetic io::Error loses the original message and source chain. Using Arc keeps content intact and restores derive(Clone).

Apply:

-#[derive(thiserror::Error, Debug)]
+#[derive(thiserror::Error, Debug, Clone)]
 pub enum Error {
-  #[error("transport error: {0}")]
-  Transport(Box<dyn std::error::Error + Send + Sync>),
+  #[error("transport error: {0}")]
+  Transport(std::sync::Arc<dyn std::error::Error + Send + Sync>),
   ...
 }

-impl Clone for Error {
-  fn clone(&self) -> Self {
-    match self {
-      Error::Transport(_) => Error::Transport(Box::new(std::io::Error::other("Transport error (cloned)"))),
-      ...
-    }
-  }
-}
+// Manual Clone impl no longer needed

And update constructors/usages:

- Error::Transport(e.into())
+ Error::Transport(std::sync::Arc::from(e))

Also applies to: 85-105

🤖 Prompt for AI Agents
In rs/moq/src/error.rs around lines 6 to 8 (and similarly for the Transport
variant usages at lines 85-105), the Transport variant currently wraps a Box<dyn
Error> and cloning falls back to a synthetic io::Error losing the original
message/source chain; change the variant to Transport(std::sync::Arc<dyn
std::error::Error + Send + Sync + 'static>) so the inner error can be shared and
the enum can derive Clone, then update all constructors and places that create
Transport to wrap the existing Box (or concrete error) in Arc::new(...) and
update any pattern matches or downcasts accordingly (e.g., when creating
Transport from errors convert them to Arc; when extracting use Arc cloning or
as_ref() without recreating io::Error).

@kixelated kixelated merged commit 7ae3a18 into main Sep 4, 2025
1 of 2 checks passed
@kixelated kixelated deleted the interface branch September 4, 2025 18:14
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
rs/moq-native/src/client.rs (1)

154-161: Add an HTTP client timeout for fingerprint fetch.

reqwest::get has no default timeout; a hung endpoint will stall connection setup.

-            let resp = reqwest::get(fingerprint.as_str())
-                .await
-                .context("failed to fetch fingerprint")?
-                .error_for_status()
-                .context("fingerprint request failed")?;
+            let client = reqwest::Client::builder()
+                .timeout(std::time::Duration::from_secs(5))
+                .build()
+                .context("failed to build reqwest client")?;
+            let resp = client
+                .get(fingerprint.as_str())
+                .send()
+                .await
+                .context("failed to fetch fingerprint")?
+                .error_for_status()
+                .context("fingerprint request failed")?;
js/hang/src/connection.ts (1)

66-99: Fix: update status and schedule reconnect on clean close (not only on errors).

If the connection closes normally, status can remain "connected" and no reconnect is scheduled because the catch block isn't hit. Add a post-close transition and (optional) backoff when reload is true.

         const connection = await Promise.race([cancel, pending]);
         if (!connection) {
           pending.then((conn) => conn.close()).catch(() => {});
           return;
         }

         effect.set(this.established, connection);
         effect.cleanup(() => connection.close());

         effect.set(this.status, "connected", "disconnected");

         // Reset the exponential backoff on success.
         this.#delay = this.delay;

-        await Promise.race([cancel, connection.closed()]);
+        await Promise.race([cancel, connection.closed()]);
+        // Closed (not canceled). Mark disconnected and optionally schedule a reconnect.
+        effect.set(this.status, "disconnected");
+        if (this.reload) {
+          const tick = this.#tick.peek() + 1;
+          effect.timer(() => this.#tick.set((prev) => Math.max(prev, tick)), this.#delay);
+          this.#delay = Math.min(this.#delay * 2, this.maxDelay) as Time.Milli;
+        }
+        return;
       } catch (err) {
         console.warn("connection error:", err);
js/hang/src/publish/audio/index.ts (1)

188-192: Defer group creation until after polyfill is ready; fix first-timestamp rotation edge case.

If the polyfill load is cancelled/unsupported, the pre-created group (Lines 188-192) remains open indefinitely. Also, initializing groupTimestamp to 0 can force an immediate rotation for non-zero first timestamps.

Apply:

-    let group: Moq.GroupProducer = this.#track.appendGroup();
-    effect.cleanup(() => group.close());
-
-    let groupTimestamp = 0 as Time.Micro;
+    // group is created after the polyfill is ready to avoid leaking an idle group.

     effect.spawn(async (cancel) => {
       // We're using an async polyfill temporarily for Safari support.
       const loaded = await Promise.race([libav.polyfill(), cancel]);
       if (!loaded) return; // cancelled

+      let group: Moq.GroupProducer = this.#track.appendGroup();
+      effect.cleanup(() => group.close());
+      let groupTimestamp: Time.Micro | undefined;
 
       const encoder = new AudioEncoder({
         output: (frame) => {
           if (frame.type !== "key") {
             throw new Error("only key frames are supported");
           }
 
-          if (frame.timestamp - groupTimestamp >= Time.Micro.fromMilli(this.maxLatency)) {
+          if (groupTimestamp === undefined) {
+            groupTimestamp = frame.timestamp as Time.Micro;
+          }
+          if (frame.timestamp - groupTimestamp >= Time.Micro.fromMilli(this.maxLatency)) {
             group.close();
             group = this.#track.appendGroup();
             groupTimestamp = frame.timestamp as Time.Micro;
           }
 
           const buffer = Frame.encode(frame, frame.timestamp as Time.Micro);
           group.writeFrame(buffer);

Also applies to: 193-197, 200-211

♻️ Duplicate comments (13)
rs/moq-relay/src/web.rs (6)

198-201: Good fix: optional Path for /announced prefix.

Accepting Path<Option<String>> and defaulting to "" addresses the extractor failure when no suffix is provided.


96-104: Fix /certificate.sha256 handler and avoid panic when no fingerprints are present (HTTPS optional).

  • Using expect("missing certificate") contradicts “HTTPS optional”.
  • get(fingerprint) passes a String where a handler is required.

Apply:

-		// Get the first certificate's fingerprint.
-		// TODO serve all of them so we can support multiple signature algorithms.
-		let fingerprint = self.state.fingerprints.first().expect("missing certificate").clone();
-
-		let app = Router::new()
-			.route("/certificate.sha256", get(fingerprint))
-			.route("/announced", get(serve_announced))
-			.route("/announced/{*prefix}", get(serve_announced))
-			.route("/fetch/{*path}", get(serve_fetch));
+		// Get the first certificate's fingerprint, if any.
+		// TODO serve all of them so we can support multiple signature algorithms.
+		let fingerprint = self.state.fingerprints.first().cloned();
+
+		let mut app = Router::new()
+			.route("/announced", get(serve_announced))
+			.route("/announced/{*prefix}", get(serve_announced))
+			.route("/fetch/{*path}", get(serve_fetch));
+
+		if let Some(fp) = fingerprint {
+			// Serve a constant body with the fingerprint.
+			app = app.route("/certificate.sha256", get(move || async move { fp.clone() }));
+		}

150-151: Return 401 on auth failure instead of propagating internal errors (WS).

Map verification errors to UNAUTHORIZED so clients get a clear 401.

-	let token = state.auth.verify(&path, params.jwt.as_deref())?;
+	let token = state
+		.auth
+		.verify(&path, params.jwt.as_deref())
+		.map_err(|_| StatusCode::UNAUTHORIZED)?;

203-204: Return 401 on auth failure instead of propagating internal errors (/announced).

Consistent with WS and /fetch.

-	let token = state.auth.verify(&prefix, params.jwt.as_deref())?;
+	let token = state
+		.auth
+		.verify(&prefix, params.jwt.as_deref())
+		.map_err(|_| StatusCode::UNAUTHORIZED)?;

236-237: Return 401 on auth failure instead of propagating internal errors (/fetch).

Keeps API semantics consistent.

-	let token = state.auth.verify(&broadcast, params.jwt.as_deref())?;
+	let token = state
+		.auth
+		.verify(&broadcast, params.jwt.as_deref())
+		.map_err(|_| StatusCode::UNAUTHORIZED)?;

227-233: Harden path parsing for /fetch to tolerate extra slashes and empty segments.

Current split can yield empty segments (e.g., “/a//b/”), producing an empty track name. Guard and return 400 for invalid paths.

-	// The path containts a broadcast/track
-	let mut path: Vec<&str> = path.split("/").collect();
-	let track = path.pop().unwrap().to_string();
+	// The path contains a broadcast/track; tolerate leading/trailing/multiple slashes.
+	let mut path: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
+	let Some(track) = path.pop().map(|s| s.to_string()) else {
+		return Err(StatusCode::BAD_REQUEST.into());
+	};
 
 	// We need at least a broadcast and a track.
 	if path.is_empty() {
 		return Err(StatusCode::BAD_REQUEST.into());
 	}
rs/moq-native/src/client.rs (1)

188-192: Bug: matching url.scheme() against moq_lite::ALPN makes the moql branch unreachable.

Compare to the literal "moql" (as done in the ALPN match above) to avoid hitting unreachable!().

-    let session = match url.scheme() {
-        "https" => web_transport_quinn::Session::connect(connection, url).await?,
-        moq_lite::ALPN => web_transport_quinn::Session::raw(connection, url),
-        _ => unreachable!(),
-    };
+    let session = match url.scheme() {
+        "https" => web_transport_quinn::Session::connect(connection, url).await?,
+        "moql"  => web_transport_quinn::Session::raw(connection, url),
+        _ => unreachable!(),
+    };
#!/bin/bash
# Find any other places comparing a URL scheme to an ALPN constant
rg -nP 'url\.scheme\(\).*moq_lite::ALPN|match\s+url\.scheme\(\)[\s\S]*moq_lite::ALPN'
rs/moq-native/src/server.rs (3)

113-119: Prevent booting without any certificates

If neither tls-cert/ tls-key nor tls-generate yields certs, the server starts but handshakes will fail. Enforce at least one cert loaded/generated.

 	let fingerprints = serve.fingerprints();
+	anyhow::ensure!(
+		!fingerprints.is_empty(),
+		"no TLS certificates configured; provide --tls-cert/--tls-key or --tls-generate"
+	);

202-209: ALPN handling inconsistent with advertised list

Only web_transport_quinn::ALPN is accepted; if moq_lite is intended, add a match arm; otherwise remove it from ALPN (see above).


120-123: Stop advertising moq_lite ALPN unless it’s actually handled

ALPN includes moq_lite, but accept_session rejects anything except WebTransport, causing misleading handshakes.

-		tls.alpn_protocols = vec![
-			web_transport_quinn::ALPN.as_bytes().to_vec(),
-			moq_lite::ALPN.as_bytes().to_vec(),
-		];
+		tls.alpn_protocols = vec![
+			web_transport_quinn::ALPN.as_bytes().to_vec(),
+		];
rs/moq/src/error.rs (1)

8-9: Good: transport errors now Arc-based and Clone-safe.

Switching to Arc<dyn Error + Send + Sync> restores derive(Clone) without losing error context. Matches prior guidance.

rs/moq/src/session/writer.rs (1)

33-47: Addresses earlier partial-write concern via write_all.

Introducing write_all(Buf) and keeping write private prevents accidental partial writes. This resolves the prior issue.

js/moq/src/connection.ts (1)

129-141: Harden fingerprint fetch: check HTTP status, trim, and validate hex.

A non-200 (e.g., HTML error) or trailing newlines will currently flow into Hex.toBytes, causing opaque failures.

-		// Fetch the fingerprint from the server.
-		const fingerprint = await Promise.race([fetch(fingerprintUrl), cancel]);
-		if (!fingerprint) return undefined;
-
-		const fingerprintText = await Promise.race([fingerprint.text(), cancel]);
-		if (fingerprintText === undefined) return undefined;
+		// Fetch the fingerprint from the server.
+		const res = await Promise.race([fetch(fingerprintUrl), cancel]);
+		if (!res) return undefined;
+		if (!res.ok) {
+			console.warn(fingerprintUrl.toString(), `fingerprint fetch failed: ${res.status} ${res.statusText}`);
+			return undefined;
+		}
+		const fingerprintText = (await Promise.race([res.text(), cancel]))?.trim();
+		if (!fingerprintText) return undefined;
+		// Optional: basic hex sanity check (64 hex chars for sha-256)
+		if (!/^[\da-fA-F]{64}$/.test(fingerprintText)) {
+			console.warn(fingerprintUrl.toString(), "invalid sha-256 fingerprint format");
+			return undefined;
+		}
🧹 Nitpick comments (32)
rs/moq-relay/src/web.rs (1)

105-109: Limit WS fallback route to GET to avoid 400s on non-WS methods.

any(serve_ws) can intercept non-WS requests to unknown paths and return 400 due to extractor failure. GET is sufficient for WS handshakes.

-			true => app.route("/{*path}", any(serve_ws)),
+			true => app.route("/{*path}", get(serve_ws)),
rs/moq-native/Cargo.toml (1)

16-16: Consider gating anyhow's backtrace feature.

Enabling anyhow/backtrace globally increases build size; make it opt-in via a crate feature.

-anyhow = { version = "1", features = ["backtrace"] }
+anyhow = "1"

Add to this Cargo.toml (outside the shown range):

[features]
default = []
backtrace = ["anyhow/backtrace"]
rs/moq-native/src/client.rs (3)

169-177: De-duplicate scheme→ALPN and scheme→session logic.

Derive both from a single parsed enum to prevent drift between matches.

Example (outside shown range):

enum Scheme { Https, Moql }
impl TryFrom<&str> for Scheme {
    type Error = anyhow::Error;
    fn try_from(s: &str) -> anyhow::Result<Self> {
        match s { "https" => Ok(Scheme::Https), "moql" => Ok(Scheme::Moql), _ => anyhow::bail!("unsupported") }
    }
}

Use it to set ALPN and choose Session::{connect,raw}.


113-118: Gate BBR with a feature or config knob.

Keep it off by default, but make it easy to enable for experiments.

-//transport.congestion_controller_factory(Arc::new(quinn::congestion::BbrConfig::default()));
+#[cfg(feature = "bbr")]
+transport.congestion_controller_factory(Arc::new(quinn::congestion::BbrConfig::default()));

Add to Cargo.toml:

[features]
bbr = []

258-264: Use constant‑time compare for fingerprint equality.

Minor hardening: avoid timing leakage on mismatch.

-        if fingerprint.as_ref() == self.fingerprint.as_slice() {
+        if ring::constant_time::verify_slices_are_equal(
+            fingerprint.as_ref(),
+            self.fingerprint.as_slice()
+        ).is_ok() {
rs/moq-native/src/server.rs (2)

38-47: Make tls-cert/tls-key accept comma-delimited lists; clarify pairing semantics

Support comma-delimited env/CLI values for Vecs and clarify that certs/keys are paired by position.

-	/// Load the given certificate from disk.
+	/// Load the given certificates from disk (paired with --tls-key by position).
-	#[arg(long = "tls-cert", id = "tls-cert", env = "MOQ_SERVER_TLS_CERT")]
+	#[arg(long = "tls-cert", id = "tls-cert", env = "MOQ_SERVER_TLS_CERT", value_delimiter = ',')]
@@
-	/// Load the given key from disk.
+	/// Load the given keys from disk.
-	#[arg(long = "tls-key", id = "tls-key", env = "MOQ_SERVER_TLS_KEY")]
+	#[arg(long = "tls-key", id = "tls-key", env = "MOQ_SERVER_TLS_KEY", value_delimiter = ',')]

Also applies to: 50-55


228-252: Validate that the private key matches the certificate

Currently loads without verifying key/cert pairing; a mismatch will fail at handshake time. Consider checking SPKI equality before pushing CertifiedKey.

rs/moq/src/error.rs (1)

64-85: Reduce boilerplate: add a helper constructor for transport errors.

This avoids repeating map_err(|e| Error::Transport(Arc::new(e))) at call sites.

 impl Error {
   /// An integer code that is sent over the wire.
   pub fn to_code(&self) -> u32 {
@@
     }
   }
+
+  /// Helper to wrap transport-layer errors uniformly.
+  pub fn transport<E>(e: E) -> Self
+  where
+    E: std::error::Error + Send + Sync + 'static,
+  {
+    Self::Transport(Arc::new(e))
+  }
 }
rs/moq/src/session/stream.rs (1)

11-20: Open path is correct; consider using the helper for error wrapping.

Once Error::transport exists, you can shorten the mapping.

- let (send, recv) = session.open_bi().await.map_err(|err| Error::Transport(Arc::new(err)))?;
+ let (send, recv) = session.open_bi().await.map_err(Error::transport)?;
rs/moq/src/session/reader.rs (2)

7-10: Make Reader crate-internal to match Writer’s visibility.

Writer is pub(super) while Reader is pub. Unless external use is intended, align visibility to avoid semver surface expansion.

-pub struct Reader<S: web_transport_trait::RecvStream> {
+pub(super) struct Reader<S: web_transport_trait::RecvStream> {

20-44: Guard against unbounded buffer growth (optional).

If peers send oversized frames or malformed data, BytesMut could grow without limit. Consider a max buffer cap or Decode-level size checks to fail fast.

rs/moq/src/session/writer.rs (1)

65-70: Avoid resetting after a clean finish.

Resetting on Drop even after finish() can mis-signal errors. Track completion and only reset if not finished.

 pub(super) struct Writer<S: web_transport_trait::SendStream> {
   stream: S,
   buffer: bytes::BytesMut,
+  finished: bool,
 }
@@
   pub fn new(stream: S) -> Self {
     Self {
       stream,
       buffer: Default::default(),
+      finished: false,
     }
   }
@@
   pub async fn finish(&mut self) -> Result<(), Error> {
     self.stream.finish().await.map_err(|e| Error::Transport(Arc::new(e)))?;
+    self.finished = true;
     Ok(())
   }
@@
 impl<S: web_transport_trait::SendStream> Drop for Writer<S> {
   fn drop(&mut self) {
     // Unlike the Quinn default, we abort the stream on drop.
-    self.stream.reset(Error::Cancel.to_code());
+    if !self.finished {
+      self.stream.reset(Error::Cancel.to_code());
+    }
   }
 }

Also applies to: 49-53, 5-10, 11-17

rs/moq/src/session/publisher.rs (2)

202-276: Surface group-task errors for debugging (optional).

Currently, completions of old_group/new_group futures ignore their Result. Log errors to aid diagnosis without changing behavior.

-        Some(_) = async { Some(old_group.as_mut()?.await) } => {
+        Some(res) = async { Some(old_group.as_mut()?.await) } => {
+          if let Err(err) = res {
+            tracing::debug!(subscribe = %subscribe.id, track = %track.info.name, %err, "old group task ended with error");
+          }
           old_group = None;
           old_sequence = None;
           continue;
         },
-        Some(_) = async { Some(new_group.as_mut()?.await) } => {
+        Some(res) = async { Some(new_group.as_mut()?.await) } => {
+          if let Err(err) = res {
+            tracing::debug!(subscribe = %subscribe.id, track = %track.info.name, %err, "new group task ended with error");
+          }
           new_group = old_group;
           new_sequence = old_sequence;
           old_group = None;
           old_sequence = None;
           continue;
         },

263-264: Prefer debug_assert! to avoid panicking in release.

Keep the check in debug builds while preventing a hard crash in production.

-      assert!(old_group.is_none());
+      debug_assert!(old_group.is_none());
js/hang/src/frame.ts (2)

276-283: Make endianness explicit in 2‑byte varint read.

DataView defaults to big‑endian, but being explicit avoids ambiguity and future regressions.

-    v = view.getUint16(0) & 0x3fff;
+    v = view.getUint16(0, false) & 0x3fff;

17-29: Minor: simplify allocation math.

source and Source both expose byteLength; the ternary duplicates the same value.

-  const data = new Uint8Array(8 + (source instanceof Uint8Array ? source.byteLength : source.byteLength));
+  const data = new Uint8Array(8 + source.byteLength);
...
-  return data.subarray(0, (source instanceof Uint8Array ? source.byteLength : source.byteLength) + size);
+  return data.subarray(0, source.byteLength + size);
js/justfile (4)

11-11: Prefer explicit workspace flag for installs.

Use pnpm -w install (or pnpm -w i) to make it explicit that the install is at the workspace root.

-	pnpm i
+	pnpm -w i

15-20: Deterministic CI installs and clearer intent.

Minor clarity: use -w for workspace-root installs; optionally add --prefer-offline to leverage CI cache.

-check:
-	pnpm install --frozen-lockfile
+check:
+	pnpm -w install --frozen-lockfile
 	pnpm -r run check
 	pnpm -r test
 	pnpm exec biome check

23-26: Keep fix fast but consistent.

Use workspace flag for install; leave lockfile unfrozen for fixes.

-fix:
-	pnpm install
+fix:
+	pnpm -w install
 	pnpm exec biome check --fix

34-37: Align build with CI determinism.

Same note: workspace flag improves intent/readability.

-build:
-	pnpm install --frozen-lockfile
+build:
+	pnpm -w install --frozen-lockfile
 	pnpm -r run build
js/moq/src/connection.ts (3)

1-1: Consider dynamic import for the WS polyfill to trim bundle size.

Static import ships the fallback to all users. Lazy-load only if/when the fallback path is taken.

Example approach:

// lazy: only load when needed
async function connectWebSocket(url: URL, cancel: Promise<void>): Promise<WebTransport | undefined> {
	const { default: WebTransportWs } = await import("@kixelated/web-transport-ws");
	const quic = new WebTransportWs(url);
	// ... rest unchanged
}

46-53: Nit: feature-detect via typeof for consistency.

Minor consistency/readability tweak.

-	const webtransport = globalThis.WebTransport ? connectWebTransport(url, cancel, props?.webtransport) : undefined;
+	const webtransport =
+		typeof globalThis.WebTransport !== "undefined"
+			? connectWebTransport(url, cancel, props?.webtransport)
+			: undefined;

52-59: Tiny cleanup: delay promise type.

Avoid declaring a generic you don’t resolve; keeps types exact.

-			? new Promise<WebTransport>((resolve) => setTimeout(resolve, headstart)).then(() => {
+			? new Promise<void>((resolve) => setTimeout(resolve, headstart)).then(() => {
js/hang/src/connection.ts (1)

88-96: Optional: add jitter to backoff to avoid synchronized reconnects.

A small randomization reduces thundering herd when many clients reconnect simultaneously.

- this.#delay = Math.min(this.#delay * 2, this.maxDelay) as Time.Milli;
+ const jitter = 0.8 + Math.random() * 0.4; // 0.8x–1.2x
+ this.#delay = Math.min(this.#delay * 2 * jitter, this.maxDelay) as Time.Milli;
js/hang/src/watch/audio/index.ts (3)

18-18: Nit: probable typo — "JITTER_UNDERHEAD" → "JITTER_OVERHEAD".

Purely cosmetic; makes the intent clearer.

-const JITTER_UNDERHEAD = 25 as Time.Milli;
+const JITTER_OVERHEAD = 25 as Time.Milli;

And update its single usage accordingly.


157-166: Harden init: handle polyfill/configure failures and ensure decoder flushes on teardown.

Avoid unhandled rejections if the polyfill fails or configure throws, and flush buffered frames on close.

-    effect.spawn(async (cancel) => {
-      const loaded = await Promise.race([libav.polyfill(), cancel]);
-      if (!loaded) return; // cancelled
-
-      const decoder = new AudioDecoder({
+    effect.spawn(async (cancel) => {
+      let decoder: AudioDecoder | undefined;
+      try {
+        const loaded = await Promise.race([libav.polyfill(), cancel]);
+        if (!loaded) return; // cancelled
+
+        decoder = new AudioDecoder({
           output: (data) => this.#emit(data),
           error: (error) => console.error(error),
         });
-      effect.cleanup(() => decoder.close());
+        effect.cleanup(() => decoder?.close());
 
-      const config = info.config;
-      const description = config.description ? Hex.toBytes(config.description) : undefined;
+        const config = info.config;
+        const description = config.description ? Hex.toBytes(config.description) : undefined;
 
-      decoder.configure({
-        ...config,
-        description,
-      });
+        // Optional: preflight support check to handle "codec present but unsupported" runtimes.
+        // const { supported } = await AudioDecoder.isConfigSupported({ ...config, description });
+        // if (!supported) throw new Error("Opus decode not supported");
+
+        decoder.configure({
+          ...config,
+          description,
+        });
+      } catch (e) {
+        console.error("Audio decoder initialization failed:", e);
+        return;
+      }
+      effect.cleanup(() => decoder?.flush?.().catch(() => {}));

Also applies to: 170-174


109-129: Optional: race worklet module load with cancellation.

If the component is closed quickly, we can skip loading the module.

-    effect.spawn(async () => {
+    effect.spawn(async (cancel) => {
       // Register the AudioWorklet processor
-      await context.audioWorklet.addModule(RenderWorklet);
+      const loaded = await Promise.race([context.audioWorklet.addModule(RenderWorklet), cancel]);
+      if (!loaded) return; // cancelled
js/hang/src/util/libav.ts (2)

5-7: Optional: presence check can be too coarse.

Browsers may expose AudioDecoder but lack Opus support. Consider a codec-specific preflight (e.g., Opus) and only load the polyfill if unsupported.

Example:

// Call site (watch/publish) before configure:
const { supported } = await AudioDecoder.isConfigSupported({
  codec: "opus",
  numberOfChannels,
  sampleRate,
  description,
});
if (!supported) await libav.polyfill();

9-26: Replace require with dynamic import() and add error handling
In js/hang/src/util/libav.ts, use dynamic imports for @libav.js/variant-opus and libavjs-webcodecs-polyfill wrapped in a try/catch to log failures and return false, improving ESM and SSR compatibility.

     // NOTE: we use require here to avoid tsc errors with libavjs-webcodecs-polyfill.
-    loading = Promise.all([require("@libav.js/variant-opus"), require("libavjs-webcodecs-polyfill")]).then(
-      async ([opus, libav]) => {
-        await libav.load({
-          LibAV: opus,
-          polyfill: true,
-        });
-        return true;
-      },
-    );
+    loading = (async () => {
+      try {
+        const [opus, libav]: any = await Promise.all([
+          import("@libav.js/variant-opus"),
+          import("libavjs-webcodecs-polyfill"),
+        ]);
+        await libav.load({ LibAV: opus, polyfill: true });
+        return true;
+      } catch (err) {
+        console.error("Failed to load Opus polyfill:", err);
+        return false;
+      }
+    })();

[js/hang/src/util/libav.ts:15]

js/hang/src/publish/audio/index.ts (3)

228-236: Add lightweight backpressure guard before allocating/copying.

Avoids latency blowups when encodeQueue backs up.

-      worklet.port.onmessage = ({ data }: { data: Capture.AudioFrame }) => {
+      worklet.port.onmessage = ({ data }: { data: Capture.AudioFrame }) => {
+        if (encoder.encodeQueueSize > 2) {
+          // Drop under pressure to keep latency bounded.
+          return;
+        }
         const channels = data.channels.slice(0, settings.channelCount);
         const joinedLength = channels.reduce((a, b) => a + b.length, 0);
         const joined = new Float32Array(joinedLength);

Optional: cache and reuse a Float32Array when joinedLength is stable to reduce GC pressure.


193-197: Optional: surface polyfill readiness for observability.

Consider logging/metric when the polyfill path is taken vs native, to aid debugging on Safari.


228-236: Minor: assume equal-channel lengths to avoid first reduce.

If capture guarantees equal lengths per channel, you can compute joinedLength as channels.length * channels[0].length and skip one pass. Leave as-is if not guaranteed.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between d5b810b and f583419.

⛔ Files ignored due to path filters (2)
  • js/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • rs/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (38)
  • .github/workflows/check.yml (1 hunks)
  • js/hang-demo/package.json (1 hunks)
  • js/hang-demo/vite.config.ts (1 hunks)
  • js/hang/package.json (1 hunks)
  • js/hang/src/connection.ts (4 hunks)
  • js/hang/src/frame.ts (1 hunks)
  • js/hang/src/publish/audio/index.ts (2 hunks)
  • js/hang/src/support/element.ts (2 hunks)
  • js/hang/src/support/index.ts (3 hunks)
  • js/hang/src/util/libav.ts (1 hunks)
  • js/hang/src/watch/audio/index.ts (2 hunks)
  • js/hang/src/watch/audio/render-worklet.ts (0 hunks)
  • js/hang/src/watch/element.ts (1 hunks)
  • js/justfile (1 hunks)
  • js/moq-token/package.json (1 hunks)
  • js/moq/package.json (1 hunks)
  • js/moq/src/connection.ts (4 hunks)
  • js/moq/src/stream.ts (2 hunks)
  • js/package.json (1 hunks)
  • justfile (1 hunks)
  • rs/Cargo.toml (1 hunks)
  • rs/justfile (2 hunks)
  • rs/moq-native/Cargo.toml (2 hunks)
  • rs/moq-native/src/client.rs (3 hunks)
  • rs/moq-native/src/server.rs (5 hunks)
  • rs/moq-relay/Cargo.toml (2 hunks)
  • rs/moq-relay/cfg/leaf.toml (1 hunks)
  • rs/moq-relay/src/cluster.rs (2 hunks)
  • rs/moq-relay/src/connection.rs (1 hunks)
  • rs/moq-relay/src/web.rs (4 hunks)
  • rs/moq/src/coding/varint.rs (2 hunks)
  • rs/moq/src/error.rs (2 hunks)
  • rs/moq/src/session/mod.rs (7 hunks)
  • rs/moq/src/session/publisher.rs (14 hunks)
  • rs/moq/src/session/reader.rs (3 hunks)
  • rs/moq/src/session/stream.rs (2 hunks)
  • rs/moq/src/session/subscriber.rs (12 hunks)
  • rs/moq/src/session/writer.rs (2 hunks)
💤 Files with no reviewable changes (1)
  • js/hang/src/watch/audio/render-worklet.ts
✅ Files skipped from review due to trivial changes (3)
  • js/hang/src/watch/element.ts
  • js/hang-demo/package.json
  • js/moq-token/package.json
🚧 Files skipped from review as they are similar to previous changes (10)
  • rs/moq-relay/src/cluster.rs
  • js/hang/package.json
  • js/moq/src/stream.ts
  • rs/moq/src/coding/varint.rs
  • js/hang/src/support/element.ts
  • rs/moq-relay/cfg/leaf.toml
  • rs/moq-relay/Cargo.toml
  • rs/moq/src/session/subscriber.rs
  • rs/moq/src/session/mod.rs
  • rs/moq-relay/src/connection.rs
🧰 Additional context used
📓 Path-based instructions (7)
{rs,js}/hang/**

📄 CodeRabbit inference engine (CLAUDE.md)

All media logic belongs in the hang layer (rs/hang and js/hang)

Files:

  • js/hang/src/util/libav.ts
  • js/hang/src/frame.ts
  • js/hang/src/watch/audio/index.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/connection.ts
  • js/hang/src/support/index.ts
js/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place JS/TS packages under the js/ directory

Files:

  • js/hang/src/util/libav.ts
  • js/hang/src/frame.ts
  • js/hang-demo/vite.config.ts
  • js/package.json
  • js/moq/package.json
  • js/hang/src/watch/audio/index.ts
  • js/hang/src/publish/audio/index.ts
  • js/hang/src/connection.ts
  • js/hang/src/support/index.ts
  • js/moq/src/connection.ts
  • js/justfile
justfile

📄 CodeRabbit inference engine (CLAUDE.md)

Use just as the task runner; define developer tasks in the repository justfile

Files:

  • justfile
rs/moq-relay/**

📄 CodeRabbit inference engine (CLAUDE.md)

The CDN/relay (rs/moq-relay) must not include application logic, media codecs, or track details

Files:

  • rs/moq-relay/src/web.rs
rs/**/src/**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

Rust tests should be integrated within source files (inline unit tests)

Files:

  • rs/moq-relay/src/web.rs
  • rs/moq/src/error.rs
  • rs/moq-native/src/client.rs
  • rs/moq/src/session/reader.rs
  • rs/moq/src/session/stream.rs
  • rs/moq-native/src/server.rs
  • rs/moq/src/session/publisher.rs
  • rs/moq/src/session/writer.rs
rs/**

📄 CodeRabbit inference engine (CLAUDE.md)

Place Rust crates under the rs/ directory

Files:

  • rs/moq-relay/src/web.rs
  • rs/moq/src/error.rs
  • rs/moq-native/src/client.rs
  • rs/justfile
  • rs/moq/src/session/reader.rs
  • rs/moq-native/Cargo.toml
  • rs/Cargo.toml
  • rs/moq/src/session/stream.rs
  • rs/moq-native/src/server.rs
  • rs/moq/src/session/publisher.rs
  • rs/moq/src/session/writer.rs
rs/Cargo.toml

📄 CodeRabbit inference engine (CLAUDE.md)

Maintain the Rust workspace configuration in rs/Cargo.toml

Files:

  • rs/Cargo.toml
🧠 Learnings (2)
📚 Learning: 2025-08-13T21:30:35.001Z
Learnt from: CR
PR: kixelated/moq#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-13T21:30:35.001Z
Learning: Applies to justfile : Use just as the task runner; define developer tasks in the repository justfile

Applied to files:

  • justfile
📚 Learning: 2025-08-13T21:30:35.001Z
Learnt from: CR
PR: kixelated/moq#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-13T21:30:35.001Z
Learning: Applies to rs/Cargo.toml : Maintain the Rust workspace configuration in rs/Cargo.toml

Applied to files:

  • rs/justfile
  • rs/Cargo.toml
🧬 Code graph analysis (11)
js/hang/src/watch/audio/index.ts (1)
js/hang/src/publish/audio/index.ts (4)
  • effect (106-149)
  • effect (151-164)
  • effect (166-255)
  • effect (257-279)
rs/moq-relay/src/web.rs (6)
rs/moq-relay/src/cluster.rs (3)
  • get (96-101)
  • new (58-67)
  • run (103-150)
rs/moq-native/src/server.rs (3)
  • new (86-146)
  • accept (157-178)
  • close (217-219)
rs/moq/src/session/mod.rs (3)
  • new (27-74)
  • accept (103-114)
  • close (151-153)
rs/moq-relay/src/auth.rs (1)
  • new (70-88)
rs/moq-relay/src/connection.rs (1)
  • run (14-59)
rs/hang-cli/src/server.rs (3)
  • server (14-37)
  • publish (89-102)
  • accept (39-64)
js/hang/src/publish/audio/index.ts (2)
js/hang/src/watch/audio/index.ts (3)
  • effect (85-130)
  • effect (132-142)
  • effect (144-194)
js/hang/src/publish/audio/capture.ts (1)
  • AudioFrame (3-6)
js/hang/src/connection.ts (2)
js/hang/src/meet/element.ts (2)
  • url (166-168)
  • url (170-172)
js/hang/src/publish/element.ts (2)
  • url (92-94)
  • url (96-98)
rs/moq-native/src/client.rs (1)
rs/hang-cli/src/client.rs (1)
  • connect (23-53)
rs/moq/src/session/reader.rs (2)
rs/moq/src/session/mod.rs (3)
  • new (27-74)
  • stream (146-146)
  • closed (156-158)
rs/moq/src/session/writer.rs (2)
  • new (12-17)
  • closed (59-62)
rs/moq/src/session/stream.rs (3)
rs/moq/src/session/mod.rs (2)
  • new (27-74)
  • accept (103-114)
rs/moq/src/session/reader.rs (1)
  • new (13-18)
rs/moq/src/session/writer.rs (1)
  • new (12-17)
rs/moq-native/src/server.rs (1)
rs/moq-native/src/client.rs (3)
  • default (42-47)
  • new (64-130)
  • new (244-246)
js/moq/src/connection.ts (3)
js/hang/src/connection.ts (2)
  • ConnectionProps (5-24)
  • Connection (28-104)
js/moq/src/lite/connection.ts (4)
  • Connection (19-198)
  • stream (127-142)
  • stream (144-160)
  • stream (181-189)
js/moq/src/ietf/connection.ts (2)
  • Connection (29-264)
  • stream (246-255)
rs/moq/src/session/publisher.rs (2)
rs/moq/src/session/writer.rs (2)
  • new (12-17)
  • finish (50-53)
rs/moq/src/session/stream.rs (1)
  • accept (22-32)
rs/moq/src/session/writer.rs (2)
rs/moq/src/session/reader.rs (1)
  • new (13-18)
rs/moq/src/coding/encode.rs (10)
  • encode (6-6)
  • encode (10-12)
  • encode (16-18)
  • encode (22-25)
  • encode (29-32)
  • encode (36-41)
  • encode (45-50)
  • encode (54-57)
  • encode (61-64)
  • encode (68-70)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Check
🔇 Additional comments (32)
rs/moq-relay/src/web.rs (1)

132-136: Confirm intended behavior when neither HTTP nor HTTPS is configured.

With both listeners absent, run() returns immediately (no servers started). If unintentional, validate config or log a warning.

I can add a small guard to log/bail; let me know the desired behavior.

rs/moq-native/Cargo.toml (2)

36-36: Switch to web-transport-quinn (workspace) looks good.

Aligns with the code changes in lib/server; no issues spotted.


23-23: rcgen 0.14 bump: manual verification needed
Ensure there are no breaking changes by running locally after configuring your Rust toolchain:

rustup default stable
cargo update -p rcgen
cargo tree -i rcgen
cargo check -p moq-native
rs/Cargo.toml (1)

23-25: Modular transport split (quinn/trait/ws) is sound.

Good centralization via workspace.dependencies; no action needed.

rs/moq-native/src/server.rs (2)

88-93: Comment-only change — no action needed

BBR remains intentionally disabled; OK.


100-107: Good: enforce 1:1 cert/key pairing and iterate safely

The length check plus zip avoids index bugs and mismatches.

rs/moq/src/error.rs (1)

72-72: Code mapping preserved.

Keeping transport errors at code 4 maintains on-wire compatibility.

rs/moq/src/session/stream.rs (2)

6-9: Generic Stream looks solid.

Tying Reader/Writer to S::RecvStream/S::SendStream cleanly abstracts the transport.


22-27: Accept path consistent with open.

Symmetric error handling and construction; good.

rs/moq/src/session/reader.rs (1)

30-36: Decode/read/closed paths handle transport errors uniformly.

map_err(Error::Transport(Arc::new(...))) is applied consistently; semantics look right for EOF vs. short decode.

Also applies to: 63-67, 71-78

rs/moq/src/session/writer.rs (1)

19-31: Encode flush loop is correct.

Drains the buffer via write_buf until empty; no partial-write risk.

rs/moq/src/session/publisher.rs (4)

31-46: Control-stream dispatch is clear and resilient.

Decoding ControlType then delegating to announce/subscribe handlers keeps responsibilities separated; warnings on handler errors are appropriate.


84-136: Announce loop and graceful finish look good.

Initial ANNOUNCE_INIT followed by streaming updates, and finish() on None is correct.


156-158: Consistent classification for transport-layer cancellations.

Treating Error::Transport(_) similar to Cancel avoids noisy logs on normal disconnects.


279-293: serve_group: transport wrapping and finish lifecycle are correct.

Opening uni, setting priority, writing frames, and finishing cleanly is aligned with the new Writer/Reader APIs.

Also applies to: 327-331

js/package.json (1)

8-16: Dev tooling bumps look good.

No functional impact. Ensure the workspace lockfile is regenerated with pnpm 10.15.1 to avoid CI cache mismatches.

js/hang-demo/vite.config.ts (1)

23-25: LGTM: exclude LibAV variant from pre‑bundling.

This aligns with dynamic polyfill loading and avoids optimizeDeps churn.

js/moq/package.json (1)

19-25: Good: Windows‑safe test glob and WS transport dep added.

  • Test script now uses double quotes (cross‑platform).
  • Adding @kixelated/web-transport-ws as a runtime dependency matches the new fallback path.
rs/justfile (2)

138-141: CI check flow is clearer.

Running tests first, then clippy with -D warnings, then fmt check is a solid order.


150-152: Auto‑fix target is sensible.

clippy --fix with staged/dirty allowed plus fmt keeps churn low for quick cleanups.

.github/workflows/check.yml (1)

32-32: CI invocation matches repo justfile; LGTM.

nix develop --command just check aligns with the new parameterless check targets.

justfile (2)

95-99: Root check delegation looks good.

Flagless check delegating to rs/js matches the new CI entrypoint.


101-105: Root fix delegation looks good.

Consistent with check; no issues.

js/hang/src/support/index.ts (1)

126-126: Confirm downstreams updated for opus status change.

opus switched from boolean to a tri-state string. Please verify all call sites and UI bindings now handle "full" | "partial" | "none".

js/hang/src/connection.ts (2)

21-23: WebSocket fallback plumbing looks good.

Prop is surfaced with sane default, stored as readonly, and forwarded to Moq.connect(...). Matches the PR goal.

Also applies to: 49-49, 68-68


26-26: Removal of “unsupported” is safe
Verified no internal call sites in js/hang reference the removed literal.

js/hang/src/publish/audio/index.ts (6)

7-7: Polyfill import placement is correct.

Lives under js/hang and aligns with the “media logic in hang layer” guideline. No issues.


198-217: Encoder init and cleanup look solid.

Output/err handlers wired, and encoder closed on cleanup. Good.


219-224: Encoder.configure parameters look correct for Opus.

Sample rate and channel count match the capture context. No changes needed.


248-253: Message handler lifecycle is correct.

Encode then close the AudioData; handler cleared on cleanup. Looks good.


226-226: Config propagation timing is appropriate.

Setting #config after configure ensures the catalog isn’t published before the encoder is ready.


7-7: polyfill idempotency & failure modes verified. libav.polyfill caches its loading promise in a module-scope loading variable; the require calls and libav.load execute only on the first invocation, and all subsequent calls await the same promise without re-running heavy work. It never resolves false—unsupported environments or load errors reject the promise, so calling code must handle rejections.

Comment on lines +238 to +246
const frame = new AudioData({
format: "f32-planar",
sampleRate: worklet.context.sampleRate,
numberOfFrames: channels[0].length,
numberOfChannels: channels.length,
timestamp: data.timestamp,
data: joined,
transfer: [joined.buffer],
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove unsupported ‘transfer’ from AudioData init.

AudioDataInit does not accept a transfer list; some engines may ignore it, others may throw. It doesn’t zero-copy anyway.

         const frame = new AudioData({
           format: "f32-planar",
           sampleRate: worklet.context.sampleRate,
           numberOfFrames: channels[0].length,
           numberOfChannels: channels.length,
           timestamp: data.timestamp,
           data: joined,
-          transfer: [joined.buffer],
         });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const frame = new AudioData({
format: "f32-planar",
sampleRate: worklet.context.sampleRate,
numberOfFrames: channels[0].length,
numberOfChannels: channels.length,
timestamp: data.timestamp,
data: joined,
transfer: [joined.buffer],
});
const frame = new AudioData({
format: "f32-planar",
sampleRate: worklet.context.sampleRate,
numberOfFrames: channels[0].length,
numberOfChannels: channels.length,
timestamp: data.timestamp,
data: joined,
});
🤖 Prompt for AI Agents
In js/hang/src/publish/audio/index.ts around lines 238 to 246, the AudioData
constructor is being passed a non-standard 'transfer' property in the init
object which AudioDataInit does not accept; remove the transfer entry from the
init object and pass only the supported fields (format, sampleRate,
numberOfFrames, numberOfChannels, timestamp, data). Ensure you do not attempt to
use a transfer list for zero-copy here—just drop the transfer key so the
AudioData is constructed with the valid properties.

Comment on lines +8 to 9
opus: Partial;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid shadowing TypeScript’s built-in Partial<T>; rename the status type.

Using the name Partial for a non-generic union shadows TS’s global Partial<T> and can cause confusing errors/import accidents.

Apply these diffs here, and add the new alias at the top of the file (see snippet below):

-	opus: Partial;
+	opus: SupportLevel;

Additional changes in this file within changed lines:

-	webtransport: Partial;
+	webtransport: SupportLevel;
-		capture: Partial;
+		capture: SupportLevel;
-		webtransport: typeof WebTransport !== "undefined" ? "full" : "partial",
+		webtransport: typeof WebTransport !== "undefined" ? "full" : "partial",
-							opus: (await audioEncoderSupported("opus")) ? "full" : "partial",
+							opus: (await audioEncoderSupported("opus")) ? "full" : "partial",
-							opus: (await audioDecoderSupported("opus")) ? "full" : "partial",
+							opus: (await audioDecoderSupported("opus")) ? "full" : "partial",

Add this alias (outside the changed hunk) to replace the conflicting name:

// Rename the status type to avoid clashing with TS's Partial<T>
export type SupportLevel = "full" | "partial" | "none";

Follow-ups: update any imports/usages in the repo that referenced the exported Partial.

🤖 Prompt for AI Agents
In js/hang/src/support/index.ts around lines 8 to 9, the file defines and
exports a non-generic type named Partial which shadows TypeScript’s global
Partial<T>; rename this status type to SupportLevel and add the new alias at the
top of the file: export type SupportLevel = "full" | "partial" | "none"; then
replace the old Partial type name and its export with SupportLevel throughout
this file (including the union use at lines 8–9) and update any other files in
the repo that import or reference the exported Partial to import/use
SupportLevel instead.

Comment on lines +31 to +32
const websocketWon = new Map<URL, boolean>();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix cache key: Map is keyed by URL object identity, not by address.

Map<URL, boolean> won’t match across calls unless the same URL instance is reused. Key by a stable string (e.g., origin) instead.

-const websocketWon = new Map<URL, boolean>();
+const websocketWon = new Map<string, boolean>();

And adjust usages:

-const headstart = !webtransport || websocketWon.get(url) ? 0 : 200;
+const origin = url.origin;
+const headstart = !webtransport || websocketWon.get(origin) ? 0 : 200;
-		websocketWon.set(url, true);
+		websocketWon.set(origin, true);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const websocketWon = new Map<URL, boolean>();
// js/moq/src/connection.ts
// Use a string key (origin) rather than URL object identity
const websocketWon = new Map<string, boolean>();
// …later, when computing headstart:
const origin = url.origin;
const headstart = !webtransport || websocketWon.get(origin) ? 0 : 200;
// …and when marking a WebSocket as “won”:
websocketWon.set(origin, true);
🤖 Prompt for AI Agents
In js/moq/src/connection.ts around lines 31-32, the Map is currently keyed by
URL object identity (Map<URL, boolean>), which fails to match across calls;
change the map to Map<string, boolean> and key it with a stable string (e.g.,
url.origin or url.toString()) instead of the URL instance, and update all
get/set/delete usages to convert incoming URL arguments to that same string key
before accessing the map so lookups are consistent across different URL objects
representing the same address.


serde = { version = "1", features = ["derive"] }
tokio = "1.45"
tokio = "1.47"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Tokio bump to 1.47: confirm toolchain/MSRV and dependency convergence.

Ensure the workspace uses a compatible Rust toolchain and there are no duplicate Tokio versions.


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Show toolchain
fd -a 'rust-toolchain*' -u | xargs -I{} sh -c 'echo --- {}; sed -n "1,200p" "{}"'
# Check Tokio graph
cargo tree -i tokio
# Build the workspace
cargo check --workspace

Length of output: 467


Specify Rust channel to match Tokio MSRV and verify build
In rust-toolchain.toml under [toolchain], add channel = "1.70.0" (minimum required by Tokio 1.47). Then run cargo tree -i tokio and cargo check --workspace to confirm no duplicate Tokio versions and a successful build.

🤖 Prompt for AI Agents
In rs/Cargo.toml around line 21 where tokio = "1.47" is declared, the project
needs a pinned Rust toolchain matching Tokio's MSRV; add a rust-toolchain.toml
file (or update existing) with [toolchain] and channel = "1.70.0". After adding
the toolchain file, run cargo tree -i tokio to ensure no duplicate Tokio
versions and then run cargo check --workspace to verify the workspace builds
successfully; if cargo tree shows conflicting tokio versions, update dependency
versions or add overrides to align them to 1.47-compatible crates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants